diff --git a/docker/.env b/docker/.env index 389cba8..1f8e5e2 100644 --- a/docker/.env +++ b/docker/.env @@ -51,7 +51,7 @@ MYSQL_PORT=5455 # The hostname where the MinIO service is exposed MINIO_HOST=minio # 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, # allowing EXTERNAL access to the web-based console running inside the Docker container. MINIO_CONSOLE_PORT=9001 diff --git a/web/src/pages/chat/chat-container/index.less b/web/src/pages/chat/chat-container/index.less index 6cb0b53..37e4e65 100644 --- a/web/src/pages/chat/chat-container/index.less +++ b/web/src/pages/chat/chat-container/index.less @@ -6,7 +6,6 @@ .messageContainer { overflow-y: auto; padding-right: 24px; - h1, h2, h3, diff --git a/web/src/pages/write/ai-assistant-config.ts b/web/src/pages/write/ai-assistant-config.ts deleted file mode 100644 index 61d9493..0000000 --- a/web/src/pages/write/ai-assistant-config.ts +++ /dev/null @@ -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: "你是一个专业的写作助手,可以帮助用户改进文档、提供写作建议和回答相关问题。", - }; \ No newline at end of file diff --git a/web/src/pages/write/index.tsx b/web/src/pages/write/index.tsx index e48d673..4e551c7 100644 --- a/web/src/pages/write/index.tsx +++ b/web/src/pages/write/index.tsx @@ -39,9 +39,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; const { Sider, Content } = Layout; const { Option } = Select; +// --- KEY DEFINITIONS --- const LOCAL_STORAGE_TEMPLATES_KEY = 'userWriteTemplates_v4_no_restore_final'; const LOCAL_STORAGE_INIT_FLAG_KEY = 'userWriteTemplates_initialized_v4_no_restore_final'; +// 为草稿内容定义一个清晰的 key +const LOCAL_STORAGE_DRAFT_KEY = 'writeDraftContent'; interface TemplateItem { id: string; @@ -56,13 +59,11 @@ interface KnowledgeBaseItem { type MarkedHeadingToken = Tokens.Heading; type MarkedParagraphToken = Tokens.Paragraph; -type MarkedImageToken = Tokens.Image; // <-- 新增:定义图片 Token 类型 +type MarkedImageToken = Tokens.Image; type MarkedListItem = Tokens.ListItem; type MarkedListToken = Tokens.List; -// 定义插入点标记,以便在onChange时识别并移除 -// const INSERTION_MARKER = '【AI内容将插入此处】'; -const INSERTION_MARKER = ''; // 改成空字符串,发现使用插入点标记体验不佳; +const INSERTION_MARKER = ''; const Write = () => { const { t } = useTranslate('write'); @@ -70,11 +71,9 @@ const Write = () => { const [aiQuestion, setAiQuestion] = useState(''); const [isAiLoading, setIsAiLoading] = useState(false); const [dialogId] = useState(''); - // cursorPosition 存储用户点击设定的插入点位置 const [cursorPosition, setCursorPosition] = useState(null); - // showCursorIndicator 现在仅用于控制文档中是否显示 'INSERTION_MARKER', const [showCursorIndicator, setShowCursorIndicator] = useState(false); - const textAreaRef = useRef(null); // Ant Design Input.TextArea 的 ref 类型 + const textAreaRef = useRef(null); const [templates, setTemplates] = useState([]); const [isTemplateModalVisible, setIsTemplateModalVisible] = useState(false); @@ -93,20 +92,16 @@ const Write = () => { const [modelTemperature, setModelTemperature] = useState(1.0); const [knowledgeBases, setKnowledgeBases] = useState([]); const [isLoadingKbs, setIsLoadingKbs] = useState(false); - const [isStreaming, setIsStreaming] = useState(false); // 标记AI是否正在流式输出 + const [isStreaming, setIsStreaming] = useState(false); - // currentStreamedAiOutput 直接接收 useSendMessageWithSse 返回的累积内容 const [currentStreamedAiOutput, setCurrentStreamedAiOutput] = useState(''); - // 使用 useRef 存储 AI 插入点前后的内容,以及插入点位置,避免在流式更新中出现闭包陷阱 const contentBeforeAiInsertionRef = useRef(''); const contentAfterAiInsertionRef = useRef(''); const aiInsertionStartPosRef = useRef(null); - // 使用 useFetchKnowledgeList hook 获取真实数据 const { list: knowledgeList, loading: isLoadingKnowledgeList } = useFetchKnowledgeList(true); - // 使用流式消息发送钩子 const { send: sendMessage, answer, @@ -138,63 +133,75 @@ const Write = () => { [t], ); - const loadOrInitializeTemplates = useCallback(() => { - try { - const initialized = localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY); - let currentTemplates: TemplateItem[] = []; - if (initialized === 'true') { - const savedTemplatesString = localStorage.getItem( - LOCAL_STORAGE_TEMPLATES_KEY, - ); - currentTemplates = savedTemplatesString - ? JSON.parse(savedTemplatesString) - : getInitialDefaultTemplateDefinitions(); - if (!savedTemplatesString) { + //初始化逻辑 + useEffect(() => { + // 定义一个内部函数来处理初始化,避免在 useEffect 中直接使用 async + const initialize = () => { + try { + // 加载或初始化模板列表 + const initialized = localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY); + let currentTemplates: TemplateItem[] = []; + if (initialized === 'true') { + const savedTemplatesString = localStorage.getItem( + LOCAL_STORAGE_TEMPLATES_KEY, + ); + currentTemplates = savedTemplatesString + ? JSON.parse(savedTemplatesString) + : getInitialDefaultTemplateDefinitions(); + } else { + currentTemplates = getInitialDefaultTemplateDefinitions(); localStorage.setItem( LOCAL_STORAGE_TEMPLATES_KEY, JSON.stringify(currentTemplates), ); + localStorage.setItem(LOCAL_STORAGE_INIT_FLAG_KEY, 'true'); } - } else { - currentTemplates = getInitialDefaultTemplateDefinitions(); - localStorage.setItem( - LOCAL_STORAGE_TEMPLATES_KEY, - JSON.stringify(currentTemplates), - ); - localStorage.setItem(LOCAL_STORAGE_INIT_FLAG_KEY, 'true'); - } - setTemplates(currentTemplates); - if (currentTemplates.length > 0 && !selectedTemplate) { - setSelectedTemplate(currentTemplates[0].id); - setContent(currentTemplates[0].content); - } else if (selectedTemplate) { - const current = currentTemplates.find( - (ts) => ts.id === selectedTemplate, - ); - if (current) setContent(current.content); - else if (currentTemplates.length > 0) { - setSelectedTemplate(currentTemplates[0].id); - setContent(currentTemplates[0].content); - } else { - setSelectedTemplate(''); - setContent(''); - } - } - } catch (error) { - console.error('加载或初始化模板失败:', error); - message.error(t('loadTemplatesFailedError')); - const fallbackDefaults = getInitialDefaultTemplateDefinitions(); - setTemplates(fallbackDefaults); - if (fallbackDefaults.length > 0) { - setSelectedTemplate(fallbackDefaults[0].id); - setContent(fallbackDefaults[0].content); - } - } - }, [selectedTemplate, getInitialDefaultTemplateDefinitions, t]); + setTemplates(currentTemplates); + // 设定编辑器初始内容 + // 优先级 1: 尝试从 localStorage 加载上次的草稿 + const draftContent = localStorage.getItem(LOCAL_STORAGE_DRAFT_KEY); + + if (draftContent !== null) { + // 如果存在草稿,无论模板是什么,都优先恢复草稿 + setContent(draftContent); + } else if (currentTemplates.length > 0) { + // 优先级 2: 如果没有草稿,则加载第一个模板作为初始内容 + const initialTemplate = currentTemplates[0]; + setSelectedTemplate(initialTemplate.id); + setContent(initialTemplate.content); + } + // 如果既没有草稿也没有模板,内容将保持为空字符串 + } catch (error) { + console.error('加载或初始化模板与内容失败:', error); + message.error(t('loadTemplatesFailedError')); + // 出现错误时的回退逻辑 + const fallbackDefaults = getInitialDefaultTemplateDefinitions(); + setTemplates(fallbackDefaults); + if (fallbackDefaults.length > 0) { + setSelectedTemplate(fallbackDefaults[0].id); + setContent(fallbackDefaults[0].content); + } + } + }; + + initialize(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // <-- 使用空依赖数组,确保此 effect 只在组件首次挂载时运行一次! + + // 在 content 变化时,自动保存草稿到 localStorage useEffect(() => { - loadOrInitializeTemplates(); - }, [loadOrInitializeTemplates]); + // 使用防抖技术,避免过于频繁地写入 localStorage + const timer = setTimeout(() => { + // 只有在 content 不是初始空状态时才保存,避免覆盖有意义的草稿为空内容 + if (content) { + localStorage.setItem(LOCAL_STORAGE_DRAFT_KEY, content); + } + }, 1000); // 延迟1秒保存 + + // 组件卸载或 content 更新时,清除上一个计时器 + return () => clearTimeout(timer); + }, [content]); // 将 knowledgeList 数据同步到 knowledgeBases 状态 useEffect(() => { @@ -210,31 +217,26 @@ const Write = () => { }, [knowledgeList, isLoadingKnowledgeList]); // --- 调整流式响应处理逻辑 --- - // 阶段1: 累积 AI 输出片段,用于实时显示(包括 标签) useEffect(() => { if (isStreaming && answer && answer.answer) { setCurrentStreamedAiOutput(answer.answer); } }, [isStreaming, answer]); - // 阶段2: 当流式输出完成时 (done 为 true) useEffect(() => { if (done) { setIsStreaming(false); setIsAiLoading(false); let processedAiOutput = currentStreamedAiOutput; if (processedAiOutput) { - // Regex to remove ... including content processedAiOutput = processedAiOutput.replace( /.*?<\/think>/gs, '', ); } - // 将最终累积的AI内容和初始文档内容拼接,更新到主内容状态 setContent((prevContent) => { if (aiInsertionStartPosRef.current !== null) { - // 使用 useRef 中存储的初始内容和最终处理过的 AI 输出 const finalContent = contentBeforeAiInsertionRef.current + processedAiOutput + @@ -244,7 +246,6 @@ const Write = () => { return prevContent; }); - // AI完成回答后,将光标实际移到新内容末尾 if ( textAreaRef.current?.resizableTextArea?.textArea && aiInsertionStartPosRef.current !== null @@ -259,65 +260,34 @@ const Write = () => { setCursorPosition(newCursorPos); } - // 清理流式相关的临时状态和 useRef - setCurrentStreamedAiOutput(''); // 清空累积内容 + setCurrentStreamedAiOutput(''); contentBeforeAiInsertionRef.current = ''; contentAfterAiInsertionRef.current = ''; aiInsertionStartPosRef.current = null; setShowCursorIndicator(true); } - }, [done, currentStreamedAiOutput]); // 依赖 done 和 currentStreamedAiOutput,确保在 done 时拿到最新的 currentStreamedAiOutput + }, [done, currentStreamedAiOutput]); - // 监听 currentStreamedAiOutput 的变化,实时更新主 content 状态以实现流式显示 useEffect(() => { if (isStreaming && aiInsertionStartPosRef.current !== null) { - // 实时更新编辑器内容,保留 标签内容 setContent( contentBeforeAiInsertionRef.current + currentStreamedAiOutput + contentAfterAiInsertionRef.current, ); - // 同时更新 cursorPosition,让光标跟随 AI 输出移动(基于包含 think 标签的原始长度) setCursorPosition( aiInsertionStartPosRef.current + currentStreamedAiOutput.length, ); } - }, [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]); + }, [currentStreamedAiOutput, isStreaming]); // 移除了 aiInsertionStartPosRef 的依赖 + // 当用户主动选择一个模板时,用模板内容覆盖当前编辑器内容 const handleTemplateSelect = (templateId: string) => { setSelectedTemplate(templateId); const item = templates.find((t) => t.id === templateId); - if (item) setContent(item.content); + if (item) { + setContent(item.content); // 这会覆盖当前内容,并触发上面的 useEffect 保存为新的草稿 + } }; const handleSaveTemplate = () => { @@ -352,7 +322,6 @@ const Write = () => { } }; - // 删除模板 const handleDeleteTemplate = (templateId: string) => { try { const updatedTemplates = templates.filter((t) => t.id !== templateId); @@ -363,8 +332,9 @@ const Write = () => { ); if (selectedTemplate === templateId) { if (updatedTemplates.length > 0) { - setSelectedTemplate(updatedTemplates[0].id); - setContent(updatedTemplates[0].content); + const newSelectedTemplate = updatedTemplates[0]; + setSelectedTemplate(newSelectedTemplate.id); + setContent(newSelectedTemplate.content); } else { setSelectedTemplate(''); setContent(''); @@ -377,26 +347,18 @@ const Write = () => { } }; - // 获取上下文内容的辅助函数 const getContextContent = ( cursorPos: number, currentDocumentContent: string, - maxLength: number = 4000, // 截取插入点上下文总共4000个字符 + maxLength: number = 4000, ) => { - // 注意: 这里的 currentDocumentContent 传入的是 AI 提问时编辑器里的总内容, - // 而不是 contentBeforeAiInsertionRef + contentAfterAiInsertionRef,因为可能包含标记 const beforeCursor = currentDocumentContent.substring(0, cursorPos); const afterCursor = currentDocumentContent.substring(cursorPos); - - // 使用更明显的插入点标记 - // const insertMarker = '[AI 内容插入点]'; const insertMarker = ''; const availableLength = maxLength - insertMarker.length; if (currentDocumentContent.length <= availableLength) { return { - beforeCursor, - afterCursor, contextContent: beforeCursor + insertMarker + afterCursor, }; } @@ -405,25 +367,20 @@ const Write = () => { let finalBefore = beforeCursor; let finalAfter = afterCursor; - // 如果前半部分太长,截断并在前面加省略号 if (beforeCursor.length > halfLength) { finalBefore = '...' + beforeCursor.substring(beforeCursor.length - halfLength + 3); } - // 如果后半部分太长,截断并在后面加省略号 if (afterCursor.length > halfLength) { finalAfter = afterCursor.substring(0, halfLength - 3) + '...'; } return { - beforeCursor, - afterCursor, contextContent: finalBefore + insertMarker + finalAfter, }; }; - // 处理问题请求提交 const handleAiQuestionSubmit = async ( e: React.KeyboardEvent, ) => { @@ -434,18 +391,15 @@ const Write = () => { return; } - // 检查是否选择了知识库 if (selectedKnowledgeBases.length === 0) { message.warning('请至少选择一个知识库'); return; } - // 如果AI正在流式输出,停止它,并处理新问题 if (isStreaming) { - stopOutputMessage(); // 停止当前的流式输出 - setIsStreaming(false); // 立即设置为false,中断流 - setIsAiLoading(false); // 确保加载状态也停止 - + stopOutputMessage(); + setIsStreaming(false); + setIsAiLoading(false); const contentToCleanOnInterrupt = contentBeforeAiInsertionRef.current + currentStreamedAiOutput + @@ -455,46 +409,39 @@ const Write = () => { '', ); setContent(cleanedContent); - - setCurrentStreamedAiOutput(''); // 清除旧的流式内容 - contentBeforeAiInsertionRef.current = ''; // 清理 useRef + setCurrentStreamedAiOutput(''); + contentBeforeAiInsertionRef.current = ''; contentAfterAiInsertionRef.current = ''; aiInsertionStartPosRef.current = null; message.info('已中断上一次AI回答,正在处理新问题...'); - // 稍作延迟,确保状态更新后再处理新问题,防止竞态条件 await new Promise((resolve) => { setTimeout(resolve, 100); }); } - // 如果当前光标位置无效,提醒用户设置 if (cursorPosition === null) { message.warning('请先点击文本框以设置AI内容插入位置。'); return; } - // 捕获 AI 插入点前后的静态内容,存储到 useRef const currentCursorPos = cursorPosition; - // 此时的 content 应该是用户当前编辑器的实际内容,包括可能存在的INSERTION_MARKER - // 但由于 INSERTION_MARKER 为空,所以就是当前的主 content contentBeforeAiInsertionRef.current = content.substring( 0, currentCursorPos, ); contentAfterAiInsertionRef.current = content.substring(currentCursorPos); - aiInsertionStartPosRef.current = currentCursorPos; // 记录确切的开始插入位置 + aiInsertionStartPosRef.current = currentCursorPos; setIsAiLoading(true); - setIsStreaming(true); // 标记AI开始流式输出 - setCurrentStreamedAiOutput(''); // 清空历史累积内容,为新的流做准备 + setIsStreaming(true); + setCurrentStreamedAiOutput(''); try { const authorization = localStorage.getItem('Authorization'); if (!authorization) { message.error(t('loginRequiredError')); setIsAiLoading(false); - setIsStreaming(false); // 停止流式标记 - // 失败时也清理临时状态 + setIsStreaming(false); setCurrentStreamedAiOutput(''); contentBeforeAiInsertionRef.current = ''; contentAfterAiInsertionRef.current = ''; @@ -502,12 +449,8 @@ const Write = () => { return; } - // 构建请求内容,将上下文内容发送给AI let questionWithContext = aiQuestion; - - // 只有当用户设置了插入位置时才包含上下文 if (aiInsertionStartPosRef.current !== null) { - // 传递给 getContextContent 的 content 应该是当前编辑器完整的,包含marker的 const { contextContent } = getContextContent( aiInsertionStartPosRef.current, content, @@ -515,7 +458,6 @@ const Write = () => { questionWithContext = `${aiQuestion}\n\n上下文内容:\n${contextContent}`; } - // 发送流式请求 await sendMessage({ question: questionWithContext, kb_ids: selectedKnowledgeBases, @@ -525,28 +467,24 @@ const Write = () => { temperature: modelTemperature, }); - setAiQuestion(''); // 清空输入框 - // 重新聚焦文本框,但不是AI问答框,而是主编辑区 + setAiQuestion(''); if (textAreaRef.current?.resizableTextArea?.textArea) { textAreaRef.current.resizableTextArea.textArea.focus(); } } catch (error: any) { console.error('AI助手处理失败:', error); - if (error.code === 'ECONNABORTED' || error.name === 'AbortError') { - message.error(t('aiRequestTimeoutError')); - } else if (error.response?.data?.message) { - message.error( - `${t('aiRequestFailedError')}: ${error.response.data.message}`, - ); - } else { - message.error(t('aiRequestFailedError')); - } + const errorMessage = + error.code === 'ECONNABORTED' || error.name === 'AbortError' + ? t('aiRequestTimeoutError') + : error.response?.data?.message + ? `${t('aiRequestFailedError')}: ${error.response.data.message}` + : t('aiRequestFailedError'); + message.error(errorMessage); } } }; const handleSave = async () => { - // 将函数声明为 async const selectedTemplateItem = templates.find( (item) => item.id === selectedTemplate, ); @@ -827,7 +765,6 @@ const Write = () => { } }; - // 修改编辑器渲染函数,添加光标标记 const renderEditor = () => { let displayContent = content; // 默认显示主内容状态 @@ -944,6 +881,7 @@ const Write = () => { ); }; + const renderPreview = () => (
{ } }; + // ... JSX 结构无变化,保持原样 return ( { renderItem={(item) => ( handleDeleteTemplate(item.id)} - okText={t('confirm')} - cancelText={t('cancel')} - > -