feat(write): 文档撰写模式,支持编辑内容的自动保存和加载
- 移除未使用的 aiAssistantConfig 文件 - 实现草稿自动保存和加载功能
This commit is contained in:
parent
70647d36d5
commit
3a1bf079fc
|
@ -51,7 +51,7 @@ MYSQL_PORT=5455
|
||||||
# The hostname where the MinIO service is exposed
|
# The hostname where the MinIO service is exposed
|
||||||
MINIO_HOST=minio
|
MINIO_HOST=minio
|
||||||
# minio上传文件时的ip地址,如需公网访问,可修改为公网ip地址
|
# minio上传文件时的ip地址,如需公网访问,可修改为公网ip地址
|
||||||
MINIO_VISIT_HOST=host.docker.internal
|
MINIO_VISIT_HOST=localhost
|
||||||
# The port used to expose the MinIO console interface to the host machine,
|
# The port used to expose the MinIO console interface to the host machine,
|
||||||
# allowing EXTERNAL access to the web-based console running inside the Docker container.
|
# allowing EXTERNAL access to the web-based console running inside the Docker container.
|
||||||
MINIO_CONSOLE_PORT=9001
|
MINIO_CONSOLE_PORT=9001
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
.messageContainer {
|
.messageContainer {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 24px;
|
padding-right: 24px;
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
export const aiAssistantConfig = {
|
|
||||||
// 模型配置
|
|
||||||
model: {
|
|
||||||
modelName: "deepseek-r1:1.5b", // 默认模型
|
|
||||||
temperature: 0.7, // 温度参数
|
|
||||||
topP: 0.9, // Top-P 参数
|
|
||||||
maxTokens: 1024, // 最大生成 token 数
|
|
||||||
},
|
|
||||||
|
|
||||||
// API 配置
|
|
||||||
api: {
|
|
||||||
// 修改为正确的 API 端点格式
|
|
||||||
chatEndpoint: "/api/v1/chats", // 创建聊天会话的端点
|
|
||||||
completionEndpoint: "/api/v1/chats/{chat_id}/completions", // 聊天完成的端点
|
|
||||||
timeout: 300000, // 请求超时时间(ms)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 默认系统提示词
|
|
||||||
systemPrompt: "你是一个专业的写作助手,可以帮助用户改进文档、提供写作建议和回答相关问题。",
|
|
||||||
};
|
|
|
@ -39,9 +39,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
|
// --- KEY DEFINITIONS ---
|
||||||
const LOCAL_STORAGE_TEMPLATES_KEY = 'userWriteTemplates_v4_no_restore_final';
|
const LOCAL_STORAGE_TEMPLATES_KEY = 'userWriteTemplates_v4_no_restore_final';
|
||||||
const LOCAL_STORAGE_INIT_FLAG_KEY =
|
const LOCAL_STORAGE_INIT_FLAG_KEY =
|
||||||
'userWriteTemplates_initialized_v4_no_restore_final';
|
'userWriteTemplates_initialized_v4_no_restore_final';
|
||||||
|
// 为草稿内容定义一个清晰的 key
|
||||||
|
const LOCAL_STORAGE_DRAFT_KEY = 'writeDraftContent';
|
||||||
|
|
||||||
interface TemplateItem {
|
interface TemplateItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -56,13 +59,11 @@ interface KnowledgeBaseItem {
|
||||||
|
|
||||||
type MarkedHeadingToken = Tokens.Heading;
|
type MarkedHeadingToken = Tokens.Heading;
|
||||||
type MarkedParagraphToken = Tokens.Paragraph;
|
type MarkedParagraphToken = Tokens.Paragraph;
|
||||||
type MarkedImageToken = Tokens.Image; // <-- 新增:定义图片 Token 类型
|
type MarkedImageToken = Tokens.Image;
|
||||||
type MarkedListItem = Tokens.ListItem;
|
type MarkedListItem = Tokens.ListItem;
|
||||||
type MarkedListToken = Tokens.List;
|
type MarkedListToken = Tokens.List;
|
||||||
|
|
||||||
// 定义插入点标记,以便在onChange时识别并移除
|
const INSERTION_MARKER = '';
|
||||||
// const INSERTION_MARKER = '【AI内容将插入此处】';
|
|
||||||
const INSERTION_MARKER = ''; // 改成空字符串,发现使用插入点标记体验不佳;
|
|
||||||
|
|
||||||
const Write = () => {
|
const Write = () => {
|
||||||
const { t } = useTranslate('write');
|
const { t } = useTranslate('write');
|
||||||
|
@ -70,11 +71,9 @@ const Write = () => {
|
||||||
const [aiQuestion, setAiQuestion] = useState('');
|
const [aiQuestion, setAiQuestion] = useState('');
|
||||||
const [isAiLoading, setIsAiLoading] = useState(false);
|
const [isAiLoading, setIsAiLoading] = useState(false);
|
||||||
const [dialogId] = useState('');
|
const [dialogId] = useState('');
|
||||||
// cursorPosition 存储用户点击设定的插入点位置
|
|
||||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||||
// showCursorIndicator 现在仅用于控制文档中是否显示 'INSERTION_MARKER',
|
|
||||||
const [showCursorIndicator, setShowCursorIndicator] = useState(false);
|
const [showCursorIndicator, setShowCursorIndicator] = useState(false);
|
||||||
const textAreaRef = useRef<any>(null); // Ant Design Input.TextArea 的 ref 类型
|
const textAreaRef = useRef<any>(null);
|
||||||
|
|
||||||
const [templates, setTemplates] = useState<TemplateItem[]>([]);
|
const [templates, setTemplates] = useState<TemplateItem[]>([]);
|
||||||
const [isTemplateModalVisible, setIsTemplateModalVisible] = useState(false);
|
const [isTemplateModalVisible, setIsTemplateModalVisible] = useState(false);
|
||||||
|
@ -93,20 +92,16 @@ const Write = () => {
|
||||||
const [modelTemperature, setModelTemperature] = useState<number>(1.0);
|
const [modelTemperature, setModelTemperature] = useState<number>(1.0);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseItem[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseItem[]>([]);
|
||||||
const [isLoadingKbs, setIsLoadingKbs] = useState(false);
|
const [isLoadingKbs, setIsLoadingKbs] = useState(false);
|
||||||
const [isStreaming, setIsStreaming] = useState(false); // 标记AI是否正在流式输出
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
|
||||||
// currentStreamedAiOutput 直接接收 useSendMessageWithSse 返回的累积内容
|
|
||||||
const [currentStreamedAiOutput, setCurrentStreamedAiOutput] = useState('');
|
const [currentStreamedAiOutput, setCurrentStreamedAiOutput] = useState('');
|
||||||
// 使用 useRef 存储 AI 插入点前后的内容,以及插入点位置,避免在流式更新中出现闭包陷阱
|
|
||||||
const contentBeforeAiInsertionRef = useRef('');
|
const contentBeforeAiInsertionRef = useRef('');
|
||||||
const contentAfterAiInsertionRef = useRef('');
|
const contentAfterAiInsertionRef = useRef('');
|
||||||
const aiInsertionStartPosRef = useRef<number | null>(null);
|
const aiInsertionStartPosRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// 使用 useFetchKnowledgeList hook 获取真实数据
|
|
||||||
const { list: knowledgeList, loading: isLoadingKnowledgeList } =
|
const { list: knowledgeList, loading: isLoadingKnowledgeList } =
|
||||||
useFetchKnowledgeList(true);
|
useFetchKnowledgeList(true);
|
||||||
|
|
||||||
// 使用流式消息发送钩子
|
|
||||||
const {
|
const {
|
||||||
send: sendMessage,
|
send: sendMessage,
|
||||||
answer,
|
answer,
|
||||||
|
@ -138,8 +133,12 @@ const Write = () => {
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadOrInitializeTemplates = useCallback(() => {
|
//初始化逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
// 定义一个内部函数来处理初始化,避免在 useEffect 中直接使用 async
|
||||||
|
const initialize = () => {
|
||||||
try {
|
try {
|
||||||
|
// 加载或初始化模板列表
|
||||||
const initialized = localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY);
|
const initialized = localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY);
|
||||||
let currentTemplates: TemplateItem[] = [];
|
let currentTemplates: TemplateItem[] = [];
|
||||||
if (initialized === 'true') {
|
if (initialized === 'true') {
|
||||||
|
@ -149,12 +148,6 @@ const Write = () => {
|
||||||
currentTemplates = savedTemplatesString
|
currentTemplates = savedTemplatesString
|
||||||
? JSON.parse(savedTemplatesString)
|
? JSON.parse(savedTemplatesString)
|
||||||
: getInitialDefaultTemplateDefinitions();
|
: getInitialDefaultTemplateDefinitions();
|
||||||
if (!savedTemplatesString) {
|
|
||||||
localStorage.setItem(
|
|
||||||
LOCAL_STORAGE_TEMPLATES_KEY,
|
|
||||||
JSON.stringify(currentTemplates),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
currentTemplates = getInitialDefaultTemplateDefinitions();
|
currentTemplates = getInitialDefaultTemplateDefinitions();
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
@ -164,25 +157,25 @@ const Write = () => {
|
||||||
localStorage.setItem(LOCAL_STORAGE_INIT_FLAG_KEY, 'true');
|
localStorage.setItem(LOCAL_STORAGE_INIT_FLAG_KEY, 'true');
|
||||||
}
|
}
|
||||||
setTemplates(currentTemplates);
|
setTemplates(currentTemplates);
|
||||||
if (currentTemplates.length > 0 && !selectedTemplate) {
|
|
||||||
setSelectedTemplate(currentTemplates[0].id);
|
// 设定编辑器初始内容
|
||||||
setContent(currentTemplates[0].content);
|
// 优先级 1: 尝试从 localStorage 加载上次的草稿
|
||||||
} else if (selectedTemplate) {
|
const draftContent = localStorage.getItem(LOCAL_STORAGE_DRAFT_KEY);
|
||||||
const current = currentTemplates.find(
|
|
||||||
(ts) => ts.id === selectedTemplate,
|
if (draftContent !== null) {
|
||||||
);
|
// 如果存在草稿,无论模板是什么,都优先恢复草稿
|
||||||
if (current) setContent(current.content);
|
setContent(draftContent);
|
||||||
else if (currentTemplates.length > 0) {
|
} else if (currentTemplates.length > 0) {
|
||||||
setSelectedTemplate(currentTemplates[0].id);
|
// 优先级 2: 如果没有草稿,则加载第一个模板作为初始内容
|
||||||
setContent(currentTemplates[0].content);
|
const initialTemplate = currentTemplates[0];
|
||||||
} else {
|
setSelectedTemplate(initialTemplate.id);
|
||||||
setSelectedTemplate('');
|
setContent(initialTemplate.content);
|
||||||
setContent('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// 如果既没有草稿也没有模板,内容将保持为空字符串
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载或初始化模板失败:', error);
|
console.error('加载或初始化模板与内容失败:', error);
|
||||||
message.error(t('loadTemplatesFailedError'));
|
message.error(t('loadTemplatesFailedError'));
|
||||||
|
// 出现错误时的回退逻辑
|
||||||
const fallbackDefaults = getInitialDefaultTemplateDefinitions();
|
const fallbackDefaults = getInitialDefaultTemplateDefinitions();
|
||||||
setTemplates(fallbackDefaults);
|
setTemplates(fallbackDefaults);
|
||||||
if (fallbackDefaults.length > 0) {
|
if (fallbackDefaults.length > 0) {
|
||||||
|
@ -190,11 +183,25 @@ const Write = () => {
|
||||||
setContent(fallbackDefaults[0].content);
|
setContent(fallbackDefaults[0].content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedTemplate, getInitialDefaultTemplateDefinitions, t]);
|
};
|
||||||
|
|
||||||
|
initialize();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // <-- 使用空依赖数组,确保此 effect 只在组件首次挂载时运行一次!
|
||||||
|
|
||||||
|
// 在 content 变化时,自动保存草稿到 localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOrInitializeTemplates();
|
// 使用防抖技术,避免过于频繁地写入 localStorage
|
||||||
}, [loadOrInitializeTemplates]);
|
const timer = setTimeout(() => {
|
||||||
|
// 只有在 content 不是初始空状态时才保存,避免覆盖有意义的草稿为空内容
|
||||||
|
if (content) {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_DRAFT_KEY, content);
|
||||||
|
}
|
||||||
|
}, 1000); // 延迟1秒保存
|
||||||
|
|
||||||
|
// 组件卸载或 content 更新时,清除上一个计时器
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
// 将 knowledgeList 数据同步到 knowledgeBases 状态
|
// 将 knowledgeList 数据同步到 knowledgeBases 状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -210,31 +217,26 @@ const Write = () => {
|
||||||
}, [knowledgeList, isLoadingKnowledgeList]);
|
}, [knowledgeList, isLoadingKnowledgeList]);
|
||||||
|
|
||||||
// --- 调整流式响应处理逻辑 ---
|
// --- 调整流式响应处理逻辑 ---
|
||||||
// 阶段1: 累积 AI 输出片段,用于实时显示(包括 <think> 标签)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming && answer && answer.answer) {
|
if (isStreaming && answer && answer.answer) {
|
||||||
setCurrentStreamedAiOutput(answer.answer);
|
setCurrentStreamedAiOutput(answer.answer);
|
||||||
}
|
}
|
||||||
}, [isStreaming, answer]);
|
}, [isStreaming, answer]);
|
||||||
|
|
||||||
// 阶段2: 当流式输出完成时 (done 为 true)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (done) {
|
if (done) {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setIsAiLoading(false);
|
setIsAiLoading(false);
|
||||||
let processedAiOutput = currentStreamedAiOutput;
|
let processedAiOutput = currentStreamedAiOutput;
|
||||||
if (processedAiOutput) {
|
if (processedAiOutput) {
|
||||||
// Regex to remove <think>...</think> including content
|
|
||||||
processedAiOutput = processedAiOutput.replace(
|
processedAiOutput = processedAiOutput.replace(
|
||||||
/<think>.*?<\/think>/gs,
|
/<think>.*?<\/think>/gs,
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将最终累积的AI内容和初始文档内容拼接,更新到主内容状态
|
|
||||||
setContent((prevContent) => {
|
setContent((prevContent) => {
|
||||||
if (aiInsertionStartPosRef.current !== null) {
|
if (aiInsertionStartPosRef.current !== null) {
|
||||||
// 使用 useRef 中存储的初始内容和最终处理过的 AI 输出
|
|
||||||
const finalContent =
|
const finalContent =
|
||||||
contentBeforeAiInsertionRef.current +
|
contentBeforeAiInsertionRef.current +
|
||||||
processedAiOutput +
|
processedAiOutput +
|
||||||
|
@ -244,7 +246,6 @@ const Write = () => {
|
||||||
return prevContent;
|
return prevContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
// AI完成回答后,将光标实际移到新内容末尾
|
|
||||||
if (
|
if (
|
||||||
textAreaRef.current?.resizableTextArea?.textArea &&
|
textAreaRef.current?.resizableTextArea?.textArea &&
|
||||||
aiInsertionStartPosRef.current !== null
|
aiInsertionStartPosRef.current !== null
|
||||||
|
@ -259,65 +260,34 @@ const Write = () => {
|
||||||
setCursorPosition(newCursorPos);
|
setCursorPosition(newCursorPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理流式相关的临时状态和 useRef
|
setCurrentStreamedAiOutput('');
|
||||||
setCurrentStreamedAiOutput(''); // 清空累积内容
|
|
||||||
contentBeforeAiInsertionRef.current = '';
|
contentBeforeAiInsertionRef.current = '';
|
||||||
contentAfterAiInsertionRef.current = '';
|
contentAfterAiInsertionRef.current = '';
|
||||||
aiInsertionStartPosRef.current = null;
|
aiInsertionStartPosRef.current = null;
|
||||||
setShowCursorIndicator(true);
|
setShowCursorIndicator(true);
|
||||||
}
|
}
|
||||||
}, [done, currentStreamedAiOutput]); // 依赖 done 和 currentStreamedAiOutput,确保在 done 时拿到最新的 currentStreamedAiOutput
|
}, [done, currentStreamedAiOutput]);
|
||||||
|
|
||||||
// 监听 currentStreamedAiOutput 的变化,实时更新主 content 状态以实现流式显示
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming && aiInsertionStartPosRef.current !== null) {
|
if (isStreaming && aiInsertionStartPosRef.current !== null) {
|
||||||
// 实时更新编辑器内容,保留 <think> 标签内容
|
|
||||||
setContent(
|
setContent(
|
||||||
contentBeforeAiInsertionRef.current +
|
contentBeforeAiInsertionRef.current +
|
||||||
currentStreamedAiOutput +
|
currentStreamedAiOutput +
|
||||||
contentAfterAiInsertionRef.current,
|
contentAfterAiInsertionRef.current,
|
||||||
);
|
);
|
||||||
// 同时更新 cursorPosition,让光标跟随 AI 输出移动(基于包含 think 标签的原始长度)
|
|
||||||
setCursorPosition(
|
setCursorPosition(
|
||||||
aiInsertionStartPosRef.current + currentStreamedAiOutput.length,
|
aiInsertionStartPosRef.current + currentStreamedAiOutput.length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [currentStreamedAiOutput, isStreaming, aiInsertionStartPosRef]);
|
}, [currentStreamedAiOutput, isStreaming]); // 移除了 aiInsertionStartPosRef 的依赖
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadDraftContent = () => {
|
|
||||||
try {
|
|
||||||
const draftContent = localStorage.getItem('writeDraftContent');
|
|
||||||
if (
|
|
||||||
draftContent &&
|
|
||||||
!content &&
|
|
||||||
(!selectedTemplate ||
|
|
||||||
templates.find((t) => t.id === selectedTemplate)?.content === '')
|
|
||||||
) {
|
|
||||||
setContent(draftContent);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载暂存内容失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY) === 'true') {
|
|
||||||
loadDraftContent();
|
|
||||||
}
|
|
||||||
}, [content, selectedTemplate, templates]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 防抖保存,防止频繁写入 localStorage
|
|
||||||
const timer = setTimeout(
|
|
||||||
() => localStorage.setItem('writeDraftContent', content),
|
|
||||||
1000,
|
|
||||||
);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
|
// 当用户主动选择一个模板时,用模板内容覆盖当前编辑器内容
|
||||||
const handleTemplateSelect = (templateId: string) => {
|
const handleTemplateSelect = (templateId: string) => {
|
||||||
setSelectedTemplate(templateId);
|
setSelectedTemplate(templateId);
|
||||||
const item = templates.find((t) => t.id === templateId);
|
const item = templates.find((t) => t.id === templateId);
|
||||||
if (item) setContent(item.content);
|
if (item) {
|
||||||
|
setContent(item.content); // 这会覆盖当前内容,并触发上面的 useEffect 保存为新的草稿
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveTemplate = () => {
|
const handleSaveTemplate = () => {
|
||||||
|
@ -352,7 +322,6 @@ const Write = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除模板
|
|
||||||
const handleDeleteTemplate = (templateId: string) => {
|
const handleDeleteTemplate = (templateId: string) => {
|
||||||
try {
|
try {
|
||||||
const updatedTemplates = templates.filter((t) => t.id !== templateId);
|
const updatedTemplates = templates.filter((t) => t.id !== templateId);
|
||||||
|
@ -363,8 +332,9 @@ const Write = () => {
|
||||||
);
|
);
|
||||||
if (selectedTemplate === templateId) {
|
if (selectedTemplate === templateId) {
|
||||||
if (updatedTemplates.length > 0) {
|
if (updatedTemplates.length > 0) {
|
||||||
setSelectedTemplate(updatedTemplates[0].id);
|
const newSelectedTemplate = updatedTemplates[0];
|
||||||
setContent(updatedTemplates[0].content);
|
setSelectedTemplate(newSelectedTemplate.id);
|
||||||
|
setContent(newSelectedTemplate.content);
|
||||||
} else {
|
} else {
|
||||||
setSelectedTemplate('');
|
setSelectedTemplate('');
|
||||||
setContent('');
|
setContent('');
|
||||||
|
@ -377,26 +347,18 @@ const Write = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取上下文内容的辅助函数
|
|
||||||
const getContextContent = (
|
const getContextContent = (
|
||||||
cursorPos: number,
|
cursorPos: number,
|
||||||
currentDocumentContent: string,
|
currentDocumentContent: string,
|
||||||
maxLength: number = 4000, // 截取插入点上下文总共4000个字符
|
maxLength: number = 4000,
|
||||||
) => {
|
) => {
|
||||||
// 注意: 这里的 currentDocumentContent 传入的是 AI 提问时编辑器里的总内容,
|
|
||||||
// 而不是 contentBeforeAiInsertionRef + contentAfterAiInsertionRef,因为可能包含标记
|
|
||||||
const beforeCursor = currentDocumentContent.substring(0, cursorPos);
|
const beforeCursor = currentDocumentContent.substring(0, cursorPos);
|
||||||
const afterCursor = currentDocumentContent.substring(cursorPos);
|
const afterCursor = currentDocumentContent.substring(cursorPos);
|
||||||
|
|
||||||
// 使用更明显的插入点标记
|
|
||||||
// const insertMarker = '[AI 内容插入点]';
|
|
||||||
const insertMarker = '';
|
const insertMarker = '';
|
||||||
const availableLength = maxLength - insertMarker.length;
|
const availableLength = maxLength - insertMarker.length;
|
||||||
|
|
||||||
if (currentDocumentContent.length <= availableLength) {
|
if (currentDocumentContent.length <= availableLength) {
|
||||||
return {
|
return {
|
||||||
beforeCursor,
|
|
||||||
afterCursor,
|
|
||||||
contextContent: beforeCursor + insertMarker + afterCursor,
|
contextContent: beforeCursor + insertMarker + afterCursor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -405,25 +367,20 @@ const Write = () => {
|
||||||
let finalBefore = beforeCursor;
|
let finalBefore = beforeCursor;
|
||||||
let finalAfter = afterCursor;
|
let finalAfter = afterCursor;
|
||||||
|
|
||||||
// 如果前半部分太长,截断并在前面加省略号
|
|
||||||
if (beforeCursor.length > halfLength) {
|
if (beforeCursor.length > halfLength) {
|
||||||
finalBefore =
|
finalBefore =
|
||||||
'...' + beforeCursor.substring(beforeCursor.length - halfLength + 3);
|
'...' + beforeCursor.substring(beforeCursor.length - halfLength + 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果后半部分太长,截断并在后面加省略号
|
|
||||||
if (afterCursor.length > halfLength) {
|
if (afterCursor.length > halfLength) {
|
||||||
finalAfter = afterCursor.substring(0, halfLength - 3) + '...';
|
finalAfter = afterCursor.substring(0, halfLength - 3) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
beforeCursor,
|
|
||||||
afterCursor,
|
|
||||||
contextContent: finalBefore + insertMarker + finalAfter,
|
contextContent: finalBefore + insertMarker + finalAfter,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理问题请求提交
|
|
||||||
const handleAiQuestionSubmit = async (
|
const handleAiQuestionSubmit = async (
|
||||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||||
) => {
|
) => {
|
||||||
|
@ -434,18 +391,15 @@ const Write = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否选择了知识库
|
|
||||||
if (selectedKnowledgeBases.length === 0) {
|
if (selectedKnowledgeBases.length === 0) {
|
||||||
message.warning('请至少选择一个知识库');
|
message.warning('请至少选择一个知识库');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果AI正在流式输出,停止它,并处理新问题
|
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
stopOutputMessage(); // 停止当前的流式输出
|
stopOutputMessage();
|
||||||
setIsStreaming(false); // 立即设置为false,中断流
|
setIsStreaming(false);
|
||||||
setIsAiLoading(false); // 确保加载状态也停止
|
setIsAiLoading(false);
|
||||||
|
|
||||||
const contentToCleanOnInterrupt =
|
const contentToCleanOnInterrupt =
|
||||||
contentBeforeAiInsertionRef.current +
|
contentBeforeAiInsertionRef.current +
|
||||||
currentStreamedAiOutput +
|
currentStreamedAiOutput +
|
||||||
|
@ -455,46 +409,39 @@ const Write = () => {
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
setContent(cleanedContent);
|
setContent(cleanedContent);
|
||||||
|
setCurrentStreamedAiOutput('');
|
||||||
setCurrentStreamedAiOutput(''); // 清除旧的流式内容
|
contentBeforeAiInsertionRef.current = '';
|
||||||
contentBeforeAiInsertionRef.current = ''; // 清理 useRef
|
|
||||||
contentAfterAiInsertionRef.current = '';
|
contentAfterAiInsertionRef.current = '';
|
||||||
aiInsertionStartPosRef.current = null;
|
aiInsertionStartPosRef.current = null;
|
||||||
message.info('已中断上一次AI回答,正在处理新问题...');
|
message.info('已中断上一次AI回答,正在处理新问题...');
|
||||||
// 稍作延迟,确保状态更新后再处理新问题,防止竞态条件
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
setTimeout(resolve, 100);
|
setTimeout(resolve, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果当前光标位置无效,提醒用户设置
|
|
||||||
if (cursorPosition === null) {
|
if (cursorPosition === null) {
|
||||||
message.warning('请先点击文本框以设置AI内容插入位置。');
|
message.warning('请先点击文本框以设置AI内容插入位置。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 捕获 AI 插入点前后的静态内容,存储到 useRef
|
|
||||||
const currentCursorPos = cursorPosition;
|
const currentCursorPos = cursorPosition;
|
||||||
// 此时的 content 应该是用户当前编辑器的实际内容,包括可能存在的INSERTION_MARKER
|
|
||||||
// 但由于 INSERTION_MARKER 为空,所以就是当前的主 content
|
|
||||||
contentBeforeAiInsertionRef.current = content.substring(
|
contentBeforeAiInsertionRef.current = content.substring(
|
||||||
0,
|
0,
|
||||||
currentCursorPos,
|
currentCursorPos,
|
||||||
);
|
);
|
||||||
contentAfterAiInsertionRef.current = content.substring(currentCursorPos);
|
contentAfterAiInsertionRef.current = content.substring(currentCursorPos);
|
||||||
aiInsertionStartPosRef.current = currentCursorPos; // 记录确切的开始插入位置
|
aiInsertionStartPosRef.current = currentCursorPos;
|
||||||
|
|
||||||
setIsAiLoading(true);
|
setIsAiLoading(true);
|
||||||
setIsStreaming(true); // 标记AI开始流式输出
|
setIsStreaming(true);
|
||||||
setCurrentStreamedAiOutput(''); // 清空历史累积内容,为新的流做准备
|
setCurrentStreamedAiOutput('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authorization = localStorage.getItem('Authorization');
|
const authorization = localStorage.getItem('Authorization');
|
||||||
if (!authorization) {
|
if (!authorization) {
|
||||||
message.error(t('loginRequiredError'));
|
message.error(t('loginRequiredError'));
|
||||||
setIsAiLoading(false);
|
setIsAiLoading(false);
|
||||||
setIsStreaming(false); // 停止流式标记
|
setIsStreaming(false);
|
||||||
// 失败时也清理临时状态
|
|
||||||
setCurrentStreamedAiOutput('');
|
setCurrentStreamedAiOutput('');
|
||||||
contentBeforeAiInsertionRef.current = '';
|
contentBeforeAiInsertionRef.current = '';
|
||||||
contentAfterAiInsertionRef.current = '';
|
contentAfterAiInsertionRef.current = '';
|
||||||
|
@ -502,12 +449,8 @@ const Write = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建请求内容,将上下文内容发送给AI
|
|
||||||
let questionWithContext = aiQuestion;
|
let questionWithContext = aiQuestion;
|
||||||
|
|
||||||
// 只有当用户设置了插入位置时才包含上下文
|
|
||||||
if (aiInsertionStartPosRef.current !== null) {
|
if (aiInsertionStartPosRef.current !== null) {
|
||||||
// 传递给 getContextContent 的 content 应该是当前编辑器完整的,包含marker的
|
|
||||||
const { contextContent } = getContextContent(
|
const { contextContent } = getContextContent(
|
||||||
aiInsertionStartPosRef.current,
|
aiInsertionStartPosRef.current,
|
||||||
content,
|
content,
|
||||||
|
@ -515,7 +458,6 @@ const Write = () => {
|
||||||
questionWithContext = `${aiQuestion}\n\n上下文内容:\n${contextContent}`;
|
questionWithContext = `${aiQuestion}\n\n上下文内容:\n${contextContent}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送流式请求
|
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
question: questionWithContext,
|
question: questionWithContext,
|
||||||
kb_ids: selectedKnowledgeBases,
|
kb_ids: selectedKnowledgeBases,
|
||||||
|
@ -525,28 +467,24 @@ const Write = () => {
|
||||||
temperature: modelTemperature,
|
temperature: modelTemperature,
|
||||||
});
|
});
|
||||||
|
|
||||||
setAiQuestion(''); // 清空输入框
|
setAiQuestion('');
|
||||||
// 重新聚焦文本框,但不是AI问答框,而是主编辑区
|
|
||||||
if (textAreaRef.current?.resizableTextArea?.textArea) {
|
if (textAreaRef.current?.resizableTextArea?.textArea) {
|
||||||
textAreaRef.current.resizableTextArea.textArea.focus();
|
textAreaRef.current.resizableTextArea.textArea.focus();
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('AI助手处理失败:', error);
|
console.error('AI助手处理失败:', error);
|
||||||
if (error.code === 'ECONNABORTED' || error.name === 'AbortError') {
|
const errorMessage =
|
||||||
message.error(t('aiRequestTimeoutError'));
|
error.code === 'ECONNABORTED' || error.name === 'AbortError'
|
||||||
} else if (error.response?.data?.message) {
|
? t('aiRequestTimeoutError')
|
||||||
message.error(
|
: error.response?.data?.message
|
||||||
`${t('aiRequestFailedError')}: ${error.response.data.message}`,
|
? `${t('aiRequestFailedError')}: ${error.response.data.message}`
|
||||||
);
|
: t('aiRequestFailedError');
|
||||||
} else {
|
message.error(errorMessage);
|
||||||
message.error(t('aiRequestFailedError'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// 将函数声明为 async
|
|
||||||
const selectedTemplateItem = templates.find(
|
const selectedTemplateItem = templates.find(
|
||||||
(item) => item.id === selectedTemplate,
|
(item) => item.id === selectedTemplate,
|
||||||
);
|
);
|
||||||
|
@ -827,7 +765,6 @@ const Write = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修改编辑器渲染函数,添加光标标记
|
|
||||||
const renderEditor = () => {
|
const renderEditor = () => {
|
||||||
let displayContent = content; // 默认显示主内容状态
|
let displayContent = content; // 默认显示主内容状态
|
||||||
|
|
||||||
|
@ -944,6 +881,7 @@ const Write = () => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPreview = () => (
|
const renderPreview = () => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
@ -995,6 +933,7 @@ const Write = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ... JSX 结构无变化,保持原样
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
style={{
|
style={{
|
||||||
|
@ -1055,6 +994,7 @@ const Write = () => {
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
actions={[
|
actions={[
|
||||||
|
item.isCustom && ( // 只对自定义模板显示删除按钮
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
key="delete"
|
key="delete"
|
||||||
title={t('confirmDeleteTemplate')}
|
title={t('confirmDeleteTemplate')}
|
||||||
|
@ -1068,7 +1008,8 @@ const Write = () => {
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</Popconfirm>,
|
</Popconfirm>
|
||||||
|
),
|
||||||
]}
|
]}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
@ -1275,13 +1216,11 @@ const Write = () => {
|
||||||
onKeyDown={handleAiQuestionSubmit}
|
onKeyDown={handleAiQuestionSubmit}
|
||||||
disabled={isAiLoading}
|
disabled={isAiLoading}
|
||||||
/>
|
/>
|
||||||
|
{isStreaming ? (
|
||||||
{/* 插入位置提示 或 AI正在回答时的提示 - 现已常驻显示 */}
|
|
||||||
{isStreaming ? ( // AI正在回答时优先显示此提示
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#faad14', // 警告色
|
color: '#faad14',
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
backgroundColor: '#fffbe6',
|
backgroundColor: '#fffbe6',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
|
@ -1290,8 +1229,7 @@ const Write = () => {
|
||||||
>
|
>
|
||||||
✨ AI正在生成回答,请稍候...
|
✨ AI正在生成回答,请稍候...
|
||||||
</div>
|
</div>
|
||||||
) : // AI未回答时
|
) : cursorPosition !== null ? (
|
||||||
cursorPosition !== null ? ( // 如果光标已设置
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
|
@ -1305,11 +1243,10 @@ const Write = () => {
|
||||||
💡 AI回答将插入到文档光标位置 (第 {cursorPosition} 个字符)。
|
💡 AI回答将插入到文档光标位置 (第 {cursorPosition} 个字符)。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 如果光标未设置
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#f5222d', // 错误色,提醒用户
|
color: '#f5222d',
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
backgroundColor: '#fff1f0',
|
backgroundColor: '#fff1f0',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
|
|
Loading…
Reference in New Issue