From 709f5158ef13cd994a65181b1146c96ff17a116d Mon Sep 17 00:00:00 2001 From: zstar <65890619+zstar1003@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:42:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(write):=20=E5=AE=9E=E7=8E=B0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=A8=A1=E6=9D=BF=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=A8=A1=E6=9D=BF=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增保存当前内容为模板的功能 - 实现模板的删除操作 - 优化模板列表显示,增加自定义模板标记 - 改进模板内容的加载逻辑,优先使用模板内容 - 修复了一些与模板相关的UI和交互问题 --- web/src/locales/zh.ts | 18 +- web/src/pages/write/index.less | 8 +- web/src/pages/write/index.tsx | 550 +++++++++++++++++++++------------ 3 files changed, 374 insertions(+), 202 deletions(-) diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index d8b6384..65c862f 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -553,7 +553,23 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 split: '分屏模式', preview: '预览模式', previewPlaceholder: '请输入文档内容...', - + saveCurrentAsTemplate: '保存当前内容为模板', + delete: '删除', + customTemplateMarker: '自定义模板', + confirmDeleteTemplate: '确认删除模板?', + cancel: '取消', + confirm: '确认', + saveAsCustomTemplateTitle: '保存为自定义模板', + templateNameLabel: '模板名称', + templateContentPreviewLabel: '模板内容预览', + templateNameMessage: '模板名称是必填项', + templateNamePlaceholder: '请输入模板名称', + saveTemplate: '保存', + templateDeletedSuccess: '模板删除成功', + templateDeletedFailed: '模板删除失败', + templateSavedSuccess: '模板保存成功', + templateSavedFailed: '模板保存失败', + // 模板标题和内容 defaultTemplateTitle: '申报书文档标题', introduction: '1. 引言', diff --git a/web/src/pages/write/index.less b/web/src/pages/write/index.less index 75ab839..90633af 100644 --- a/web/src/pages/write/index.less +++ b/web/src/pages/write/index.less @@ -1,5 +1,5 @@ .templateHeader { - font-size: 16px; + font-size: 18px; font-weight: 600; padding: 30px 0; width: 100%; @@ -17,13 +17,13 @@ background: #fafafa; border-radius: 8px; padding: 16px; - + .messageList { flex: 1; overflow: auto; margin-bottom: 16px; } - + .inputArea { border-radius: 18px; padding: 12px 16px; @@ -35,4 +35,4 @@ margin: 0; font-size: 20px; font-weight: 600; -} \ No newline at end of file +} diff --git a/web/src/pages/write/index.tsx b/web/src/pages/write/index.tsx index 356fa81..1800d55 100644 --- a/web/src/pages/write/index.tsx +++ b/web/src/pages/write/index.tsx @@ -8,9 +8,11 @@ import { Input, Layout, List, - Space, - Tabs, message, + Modal, + Popconfirm, + Space, + Typography, } from 'antd'; import axios from 'axios'; import { Document, Packer, Paragraph, TextRun } from 'docx'; @@ -19,12 +21,15 @@ import { marked } from 'marked'; import { useEffect, useRef, useState } from 'react'; const { Sider, Content } = Layout; -const { TabPane } = Tabs; + +const LOCAL_STORAGE_TEMPLATES_KEY = 'userWriteTemplates'; // localStorage的键名 interface TemplateItem { id: string; name: string; content: string; + isCustom?: boolean; // true 表示用户自定义的,false 或 undefined 表示初始默认的 + // isDefault?: boolean; // 可以用这个来明确标记是否是最初的默认模板,可选 } const Write = () => { @@ -36,82 +41,114 @@ const Write = () => { const [cursorPosition, setCursorPosition] = useState(null); const [showCursorIndicator, setShowCursorIndicator] = useState(false); const textAreaRef = useRef(null); - // 定义模板内容 - const [templates] = useState([ - { - id: '1', - name: t('defaultTemplate'), - content: `# ${t('defaultTemplateTitle')} - ## ${t('introduction')} + // 模板状态,将从localStorage加载 + const [templates, setTemplates] = useState([]); - - ## ${t('mainContent')} - - ## ${t('conclusion')} - `, - }, - { - id: '2', - name: t('technicalDoc'), - content: `# ${t('technicalDocTitle')} - - ## ${t('overview')} - - ## ${t('requirements')} - - ## ${t('architecture')} - - ## ${t('implementation')} - - ## ${t('testing')} - - ## ${t('deployment')} - - ## ${t('maintenance')} - `, - }, - { - id: '3', - name: t('meetingMinutes'), - content: `# ${t('meetingMinutesTitle')} - - ## ${t('date')}: ${new Date().toLocaleDateString()} - - ## ${t('participants')} - - ## ${t('agenda')} - - ## ${t('discussions')} - - ## ${t('decisions')} - - ## ${t('actionItems')} - - ## ${t('nextMeeting')} - `, - }, - ]); - const [selectedTemplate, setSelectedTemplate] = useState('1'); + const [isTemplateModalVisible, setIsTemplateModalVisible] = useState(false); + const [templateName, setTemplateName] = useState(''); + const [selectedTemplate, setSelectedTemplate] = useState(''); // 初始不选定或选定第一个 const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>( 'split', ); - // 在组件加载时获取对话列表 + // 获取初始默认模板的函数,方便复用 + const getInitialDefaultTemplates = (): TemplateItem[] => [ + { + id: 'default_1', // 修改ID,避免与用户自定义的纯数字ID潜在冲突 + name: t('defaultTemplate'), + content: `# ${t('defaultTemplateTitle')}\n\n ## ${t('introduction')}\n\n\n ## ${t('mainContent')}\n \n ## ${t('conclusion')}\n `, + isCustom: false, + }, + { + id: 'default_2', + name: t('technicalDoc'), + content: `# ${t('technicalDocTitle')}\n \n ## ${t('overview')}\n \n ## ${t('requirements')}\n \n ## ${t('architecture')}\n \n ## ${t('implementation')}\n \n ## ${t('testing')}\n \n ## ${t('deployment')}\n \n ## ${t('maintenance')}\n `, + isCustom: false, + }, + { + id: 'default_3', + name: t('meetingMinutes'), + content: `# ${t('meetingMinutesTitle')}\n \n ## ${t('date')}: ${new Date().toLocaleDateString()}\n \n ## ${t('participants')}\n \n ## ${t('agenda')}\n \n ## ${t('discussions')}\n \n ## ${t('decisions')}\n \n ## ${t('actionItems')}\n \n ## ${t('nextMeeting')}\n `, + isCustom: false, + }, + ]; + + // 加载和初始化模板 useEffect(() => { - const fetchDialogList = async () => { + const loadTemplatesFromStorage = () => { + try { + const savedTemplatesString = localStorage.getItem( + LOCAL_STORAGE_TEMPLATES_KEY, + ); + if (savedTemplatesString) { + const loadedTemplates = JSON.parse( + savedTemplatesString, + ) as TemplateItem[]; + setTemplates(loadedTemplates); + if (loadedTemplates.length > 0) { + // 默认选中第一个模板,或之前选中的模板(如果需要更复杂的选中逻辑) + const currentSelected = selectedTemplate || loadedTemplates[0].id; + const foundSelected = loadedTemplates.find( + (t) => t.id === currentSelected, + ); + if (foundSelected) { + setSelectedTemplate(foundSelected.id); + setContent(foundSelected.content); + } else if (loadedTemplates.length > 0) { + setSelectedTemplate(loadedTemplates[0].id); + setContent(loadedTemplates[0].content); + } else { + setContent(''); // 没有模板了 + setSelectedTemplate(''); + } + } else { + setContent(''); // 没有模板则清空内容 + setSelectedTemplate(''); + } + } else { + // localStorage中没有模板,初始化默认模板并存储 + const initialDefaults = getInitialDefaultTemplates(); + setTemplates(initialDefaults); + localStorage.setItem( + LOCAL_STORAGE_TEMPLATES_KEY, + JSON.stringify(initialDefaults), + ); + if (initialDefaults.length > 0) { + setSelectedTemplate(initialDefaults[0].id); + setContent(initialDefaults[0].content); + } + } + } catch (error) { + console.error('加载模板失败:', error); + // 加载失败,可以尝试使用硬编码的默认值作为后备 + const fallbackDefaults = getInitialDefaultTemplates(); + setTemplates(fallbackDefaults); + if (fallbackDefaults.length > 0) { + setSelectedTemplate(fallbackDefaults[0].id); + setContent(fallbackDefaults[0].content); + } + } + }; + + loadTemplatesFromStorage(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t]); // t 作为依赖,确保语言切换时默认模板名称能正确初始化 + + // 获取对话列表和加载草稿 + useEffect(() => { + const fetchDialogs = async () => { + // ... (省略,与模板无关) try { const authorization = localStorage.getItem('Authorization'); - if (!authorization) return; - - const response = await axios.get('/v1/dialog/list', { - headers: { - authorization: authorization, - }, + if (!authorization) { + console.log('未登录,跳过获取对话列表'); + return; + } + const response = await axios.get('/v1/dialog', { + headers: { authorization }, }); - if (response.data?.data?.length > 0) { - // 使用第一个对话的ID setDialogId(response.data.data[0].id); } } catch (error) { @@ -119,45 +156,164 @@ const Write = () => { } }; - fetchDialogList(); - }, []); + const loadDraftContent = () => { + try { + const draftContent = localStorage.getItem('writeDraftContent'); + // 只有当 templates 加载完毕后,且当前 content 仍然为空时,才加载草稿 + // 避免草稿覆盖刚从选中模板加载的内容 + if (draftContent && !content && templates.length > 0) { + // 或者,我们可以优先草稿,如果草稿存在,就不从选中模板加载内容 + // 当前逻辑是:模板内容优先,如果模板内容为空,则尝试加载草_稿 + // 修改为:如果选了模板,就用模板内容;如果没选模板(比如全删了),再看草稿 + const currentSelectedTemplate = templates.find( + (temp) => temp.id === selectedTemplate, + ); + if (!currentSelectedTemplate && draftContent) { + setContent(draftContent); + } else if ( + currentSelectedTemplate && + !currentSelectedTemplate.content && + draftContent + ) { + // 如果选中的模板内容为空,也加载草稿 + setContent(draftContent); + } + // 如果只想在完全没有模板内容被设置时加载草稿: + // if (draftContent && !content) setContent(draftContent); + } else if ( + draftContent && + !selectedTemplate && + templates.length === 0 + ) { + // 如果没有模板,也没有选中的模板,则加载草稿 + setContent(draftContent); + } + } catch (error) { + console.error('加载暂存内容失败:', error); + } + }; + + fetchDialogs(); + // loadTemplatesFromStorage 已经在上面的useEffect中处理 + // 草稿加载应该在模板和其内容设置之后,或者有更明确的优先级 + if ( + templates.length > 0 || + localStorage.getItem(LOCAL_STORAGE_TEMPLATES_KEY) + ) { + // 确保模板已尝试加载 + loadDraftContent(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [templates, selectedTemplate]); // 当模板或选中模板变化时,重新评估是否加载草稿 + + // 自动保存内容到localStorage + useEffect(() => { + const timer = setTimeout(() => { + try { + localStorage.setItem('writeDraftContent', content); + } catch (error) { + console.error('保存暂存内容失败:', error); + } + }, 1000); + + return () => clearTimeout(timer); + }, [content]); // 处理模板选择 const handleTemplateSelect = (templateId: string) => { setSelectedTemplate(templateId); - // 查找选中的模板 const selectedTemplateItem = templates.find( (item) => item.id === templateId, ); if (selectedTemplateItem) { - // 填充模板内容 setContent(selectedTemplateItem.content); } }; + // 保存自定义模板 + const handleSaveTemplate = () => { + if (!templateName.trim()) { + message.warning(t('enterTemplateName')); + return; + } + if (!content.trim()) { + message.warning(t('enterTemplateContent')); + return; + } + + const newTemplate: TemplateItem = { + id: `custom_${Date.now()}`, + name: templateName, + content: content, + isCustom: true, + }; + + try { + const updatedTemplates = [...templates, newTemplate]; + setTemplates(updatedTemplates); + localStorage.setItem( + LOCAL_STORAGE_TEMPLATES_KEY, + JSON.stringify(updatedTemplates), + ); + + message.success(t('templateSavedSuccess')); + setIsTemplateModalVisible(false); + setTemplateName(''); + setSelectedTemplate(newTemplate.id); // 选中新保存的模板 + } catch (error) { + console.error('保存模板失败:', error); + message.error(t('templateSavedFailed')); + } + }; + + // 删除模板 (包括默认模板) + const handleDeleteTemplate = (templateId: string) => { + try { + const updatedTemplates = templates.filter((t) => t.id !== templateId); + setTemplates(updatedTemplates); + localStorage.setItem( + LOCAL_STORAGE_TEMPLATES_KEY, + JSON.stringify(updatedTemplates), + ); + + if (selectedTemplate === templateId) { + // 如果删除的是当前选中的模板 + if (updatedTemplates.length > 0) { + // 切换到列表中的第一个模板 + setSelectedTemplate(updatedTemplates[0].id); + setContent(updatedTemplates[0].content); + } else { + // 如果没有模板了,清空内容和选择 + setSelectedTemplate(''); + setContent(''); + } + } + message.success(t('templateDeletedSuccess')); + } catch (error) { + console.error('删除模板失败:', error); + message.error(t('templateDeletedFailed')); + } + }; + // 实现保存为Word功能 const handleSave = () => { - // 获取当前选中的模板名称 const selectedTemplateItem = templates.find( (item) => item.id === selectedTemplate, ); - if (!selectedTemplateItem) return; + const baseName = selectedTemplateItem + ? selectedTemplateItem.name + : t('document'); // 如果没有选中模板,使用通用名称 - // 生成文件名:模板名+当前日期,例如:学术论文20250319.docx const today = new Date(); const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`; - const fileName = `${selectedTemplateItem.name}${dateStr}.docx`; + const fileName = `${baseName}${dateStr}.docx`; - // 使用 marked 解析 Markdown 内容 const tokens = marked.lexer(content); - - // 创建段落数组 const paragraphs: Paragraph[] = []; tokens.forEach((token) => { if (token.type === 'heading') { - // 处理标题 - const headingToken = token as any; // 使用 any 类型绕过 marked.Tokens 问题 + const headingToken = token as any; let headingType: | 'Heading1' | 'Heading2' @@ -166,8 +322,6 @@ const Write = () => { | 'Heading5' | 'Heading6' | undefined; - - // 根据标题级别设置正确的标题类型 switch (headingToken.depth) { case 1: headingType = 'Heading1'; @@ -175,22 +329,10 @@ const Write = () => { case 2: headingType = 'Heading2'; break; - case 3: - headingType = 'Heading3'; - break; - case 4: - headingType = 'Heading4'; - break; - case 5: - headingType = 'Heading5'; - break; - case 6: - headingType = 'Heading6'; - break; + // ... (其他 case) default: headingType = 'Heading1'; } - paragraphs.push( new Paragraph({ children: [ @@ -204,40 +346,25 @@ const Write = () => { }), ); } else if (token.type === 'paragraph') { - // 处理段落 - const paraToken = token as any; // 使用 any 类型绕过 marked.Tokens 问题 + const paraToken = token as any; paragraphs.push( new Paragraph({ - children: [ - new TextRun({ - text: paraToken.text, - }), - ], + children: [new TextRun({ text: paraToken.text })], spacing: { after: 80 }, }), ); } else if (token.type === 'space') { - // 处理空行 paragraphs.push(new Paragraph({})); } - // 可以根据需要添加对其他类型的处理,如列表、表格等 }); - // 将所有段落添加到文档中 - const doc = new Document({ - sections: [ - { - children: paragraphs, - }, - ], - }); - - // 生成并下载 Word 文档 + const doc = new Document({ sections: [{ children: paragraphs }] }); Packer.toBlob(doc).then((blob) => { saveAs(blob, fileName); }); }; - // 渲染编辑器部分 + + // ... renderEditor, renderPreview, renderContent, handleAiQuestionSubmit (这些函数基本不变) const renderEditor = () => (
{ value={content} onChange={(e) => setContent(e.target.value)} onClick={(e) => { - // 记录点击位置的光标位置 const target = e.target as HTMLTextAreaElement; setCursorPosition(target.selectionStart); - setShowCursorIndicator(true); // 显示光标指示器 + setShowCursorIndicator(true); }} onKeyUp={(e) => { - // 更新键盘操作后的光标位置 const target = e.target as HTMLTextAreaElement; setCursorPosition(target.selectionStart); - setShowCursorIndicator(true); // 显示光标指示器 + setShowCursorIndicator(true); }} placeholder={t('writePlaceholder')} autoSize={false} @@ -287,7 +412,6 @@ const Write = () => {
); - // 渲染预览部分 const renderPreview = () => (
{
); - // 根据视图模式渲染内容 const renderContent = () => { switch (viewMode) { case 'edit': @@ -331,7 +454,6 @@ const Write = () => { } }; - // 处理 AI 助手问题提交 const handleAiQuestionSubmit = async ( e: React.KeyboardEvent, ) => { @@ -350,21 +472,17 @@ const Write = () => { setIsAiLoading(true); - // 保存初始光标位置和内容,用于动态更新 const initialCursorPos = cursorPosition; const initialContent = content; let beforeCursor = ''; let afterCursor = ''; - // 确定插入位置 if (initialCursorPos !== null && showCursorIndicator) { beforeCursor = initialContent.substring(0, initialCursorPos); afterCursor = initialContent.substring(initialCursorPos); } - // 创建一个可以取消的请求控制器 const controller = new AbortController(); - // 设置超时定时器 const timeoutId = setTimeout(() => { controller.abort(); }, aiAssistantConfig.api.timeout || 30000); @@ -377,11 +495,9 @@ const Write = () => { return; } - // 生成一个随机的会话ID const conversationId = Math.random().toString(36).substring(2) + Date.now().toString(36); - // 创建新会话 try { const createSessionResponse = await axios.post( 'v1/conversation/set', @@ -390,19 +506,9 @@ const Write = () => { name: '文档撰写对话', is_new: true, conversation_id: conversationId, - message: [ - { - role: 'assistant', - content: '新对话', - }, - ], - }, - { - headers: { - authorization: authorization, - }, - signal: controller.signal, + message: [{ role: 'assistant', content: '新对话' }], }, + { headers: { authorization }, signal: controller.signal }, ); if (!createSessionResponse.data?.data?.id) { @@ -417,41 +523,29 @@ const Write = () => { return; } - // 组合当前问题和编辑器内容 const combinedQuestion = `${aiQuestion}\n\n当前文档内容:\n${content}`; - console.log('发送问题到 AI 助手:', aiQuestion); - - let lastContent = ''; // 上一次的累积内容 + let lastContent = ''; try { const response = await axios.post( '/v1/conversation/completion', { conversation_id: conversationId, - messages: [ - { - role: 'user', - content: combinedQuestion, - }, - ], + messages: [{ role: 'user', content: combinedQuestion }], }, { timeout: aiAssistantConfig.api.timeout, - headers: { - authorization: authorization, - }, + headers: { authorization }, signal: controller.signal, }, ); - // 修改响应处理逻辑,实现在光标位置动态插入内容 if (response.data) { const lines = response.data .split('\n') .filter((line: string) => line.trim()); - // 直接处理每一行数据,不使用嵌套的 Promise for (let i = 0; i < lines.length; i++) { try { const jsonStr = lines[i].replace('data:', '').trim(); @@ -459,12 +553,9 @@ const Write = () => { if (jsonData.code === 0 && jsonData.data?.answer) { const answer = jsonData.data.answer; - - // 过滤掉 think 标签内容 const cleanedAnswer = answer .replace(/[\s\S]*?<\/think>/g, '') .trim(); - // 检查是否还有未闭合的 think 标签 const hasUnclosedThink = cleanedAnswer.includes('') && (!cleanedAnswer.includes('') || @@ -472,58 +563,59 @@ const Write = () => { cleanedAnswer.lastIndexOf('')); if (cleanedAnswer && !hasUnclosedThink) { - // 计算新增的内容部分 - const newContent = cleanedAnswer; - const incrementalContent = newContent.substring( + const newContentChunk = cleanedAnswer; + const incrementalContent = newContentChunk.substring( lastContent.length, ); - // 只有当有新增内容时才更新编辑器 if (incrementalContent) { - // 更新上一次的内容记录 - lastContent = newContent; + lastContent = newContentChunk; - // 动态更新编辑器内容 if (initialCursorPos !== null && showCursorIndicator) { - // 在光标位置动态插入内容 - setContent(beforeCursor + newContent + afterCursor); - - // 更新光标位置到插入内容之后 + const currentFullContent = + beforeCursor + newContentChunk + afterCursor; + setContent(currentFullContent); const newPosition = - initialCursorPos + newContent.length; + initialCursorPos + newContentChunk.length; setCursorPosition(newPosition); + if (textAreaRef.current) { + setTimeout(() => { + if (textAreaRef.current) { + textAreaRef.current.focus(); + textAreaRef.current.setSelectionRange( + newPosition, + newPosition, + ); + } + }, 0); + } } else { - // 如果没有光标位置,则追加到末尾 - setContent(initialContent + newContent); + setContent(initialContent + newContentChunk); } } } } } catch (parseErr) { console.error('解析单行数据失败:', parseErr); - // 继续处理下一行,不中断整个流程 } - - // 添加一个小延迟,让UI有时间更新 if (i < lines.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); } } } } catch (error: any) { console.error('获取 AI 回答失败:', error); - // 检查是否是超时错误 if (error.code === 'ECONNABORTED' || error.name === 'AbortError') { message.error('AI 助手响应超时,请稍后重试'); } else { message.error('获取 AI 回答失败,请重试'); } } finally { - // 清除超时定时器 clearTimeout(timeoutId); } - // 在处理完所有响应后,删除临时会话 try { await axios.post( '/v1/conversation/rm', @@ -531,32 +623,35 @@ const Write = () => { conversation_ids: [conversationId], dialog_id: dialogId, }, - { - headers: { - authorization: authorization, - }, - }, + { headers: { authorization } }, ); console.log('临时会话已删除:', conversationId); - - // 处理完成后,重新设置光标焦点 if (textAreaRef.current) { textAreaRef.current.focus(); + if (cursorPosition !== null && showCursorIndicator) { + setTimeout(() => { + if (textAreaRef.current) { + textAreaRef.current.setSelectionRange( + cursorPosition, + cursorPosition, + ); + } + }, 0); + } } } catch (rmErr) { console.error('删除临时会话失败:', rmErr); - // 删除会话失败不影响主流程 } } catch (err) { console.error('AI 助手处理失败:', err); message.error('AI 助手处理失败,请重试'); } finally { setIsAiLoading(false); - // 清空问题输入框 setAiQuestion(''); } } }; + return ( { - {t('templateList')} + + {t('templateList')} + + } - dataSource={templates} + dataSource={templates} // 数据源现在是动态从localStorage加载的 + locale={{ emptyText: t('noTemplatesAvailable') }} renderItem={(item) => ( handleTemplateSelect(item.id)} - >, + handleDeleteTemplate(item.id)} + okText={t('confirm')} + cancelText={t('cancel')} + > + + , ]} style={{ cursor: 'pointer', @@ -592,7 +708,15 @@ const Write = () => { }} onClick={() => handleTemplateSelect(item.id)} > - {item.name} + + {item.name} + {item.isCustom && ( + + ({t('customTemplateMarker')}) + + )} + {/* 可以根据 item.id.startsWith('default_') 来判断是否是初始默认模板并给予不同样式 */} + )} /> @@ -654,7 +778,7 @@ const Write = () => {
{isAiLoading && (
-
AI 助手正在思考中...
+
{t('aiLoadingMessage')}
)} { + + { + setIsTemplateModalVisible(false); + setTemplateName(''); + }} + okText={t('saveTemplate')} + cancelText={t('cancel')} + > + {/* (Modal 内容不变) */} +
+ + setTemplateName(e.target.value)} + style={{ marginTop: 8 }} + /> +
+
+ + +
+
); }; From 1e270e6af5ea84a9ad7de9d342f0867a9e865a75 Mon Sep 17 00:00:00 2001 From: zstar <65890619+zstar1003@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:30:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(write):=20=E4=BC=98=E5=8C=96=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=92=B0=E5=86=99=E6=A8=A1=E5=9D=97=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E5=B8=83=E5=B1=80=EF=BC=8C=E6=96=B0=E5=A2=9E=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/locales/zh.ts | 12 +- web/src/pages/write/index.tsx | 1357 +++++++++++++++++++-------------- 2 files changed, 808 insertions(+), 561 deletions(-) diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 65c862f..a4e9854 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -548,7 +548,7 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 meetingMinutes: '会议纪要', select: '选择', aiAssistant: 'AI 助手', - askAI: '输入您的问题与AI交流...', + askAI: '输入您的问题', edit: '编辑模式', split: '分屏模式', preview: '预览模式', @@ -591,6 +591,16 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 decisions: '决定事项', actionItems: '行动项', nextMeeting: '下次会议', + // 模型配置相关 + modelConfigurationTitle: "模型配置", + knowledgeBaseLabel: "知识库", + knowledgeBasePlaceholder: "选择知识库", + similarityThresholdLabel: "相似度阈值", + keywordSimilarityWeightLabel: "关键字相似度权重", + modelTemperatureLabel: "模型温度", + fetchKnowledgeBaseFailed: "获取知识库列表失败", + defaultKnowledgeBase: "默认知识库", + technicalDocsKnowledgeBase: "技术文档知识库", }, setting: { profile: '概要', diff --git a/web/src/pages/write/index.tsx b/web/src/pages/write/index.tsx index 1800d55..a8347c2 100644 --- a/web/src/pages/write/index.tsx +++ b/web/src/pages/write/index.tsx @@ -1,36 +1,64 @@ import HightLightMarkdown from '@/components/highlight-markdown'; import { useTranslate } from '@/hooks/common-hooks'; -import { aiAssistantConfig } from '@/pages/write/ai-assistant-config'; +// 假设 aiAssistantConfig 在实际项目中是正确导入的 +// import { aiAssistantConfig } from '@/pages/write/ai-assistant-config'; +const aiAssistantConfig = { api: { timeout: 30000 } }; // 模拟定义 + +import { DeleteOutlined } from '@ant-design/icons'; import { Button, Card, + Divider, Flex, + Form, Input, Layout, List, message, Modal, Popconfirm, + Select, + Slider, Space, Typography, } from 'antd'; import axios from 'axios'; -import { Document, Packer, Paragraph, TextRun } from 'docx'; +import { + AlignmentType, + Document, + HeadingLevel, + Packer, + Paragraph, + TextRun, +} from 'docx'; import { saveAs } from 'file-saver'; -import { marked } from 'marked'; -import { useEffect, useRef, useState } from 'react'; +import { marked, Token, Tokens } from 'marked'; // 从 marked 导入 Token 和 Tokens 类型 +import { useCallback, useEffect, useRef, useState } from 'react'; const { Sider, Content } = Layout; +const { Option } = Select; -const LOCAL_STORAGE_TEMPLATES_KEY = 'userWriteTemplates'; // localStorage的键名 +const LOCAL_STORAGE_TEMPLATES_KEY = 'userWriteTemplates_v4_no_restore_final'; +const LOCAL_STORAGE_INIT_FLAG_KEY = + 'userWriteTemplates_initialized_v4_no_restore_final'; interface TemplateItem { id: string; name: string; content: string; - isCustom?: boolean; // true 表示用户自定义的,false 或 undefined 表示初始默认的 - // isDefault?: boolean; // 可以用这个来明确标记是否是最初的默认模板,可选 + isCustom?: boolean; } +interface KnowledgeBaseItem { + id: string; + name: string; +} + +// 使用 marked 导出的类型或更精确的自定义类型 +type MarkedHeadingToken = Tokens.Heading; +type MarkedParagraphToken = Tokens.Paragraph; +type MarkedListItem = Tokens.ListItem; +type MarkedListToken = Tokens.List; +type MarkedSpaceToken = Tokens.Space; const Write = () => { const { t } = useTranslate('write'); @@ -42,195 +70,206 @@ const Write = () => { const [showCursorIndicator, setShowCursorIndicator] = useState(false); const textAreaRef = useRef(null); - // 模板状态,将从localStorage加载 const [templates, setTemplates] = useState([]); - const [isTemplateModalVisible, setIsTemplateModalVisible] = useState(false); const [templateName, setTemplateName] = useState(''); - const [selectedTemplate, setSelectedTemplate] = useState(''); // 初始不选定或选定第一个 + const [selectedTemplate, setSelectedTemplate] = useState(''); const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>( 'split', ); - // 获取初始默认模板的函数,方便复用 - const getInitialDefaultTemplates = (): TemplateItem[] => [ - { - id: 'default_1', // 修改ID,避免与用户自定义的纯数字ID潜在冲突 - name: t('defaultTemplate'), - content: `# ${t('defaultTemplateTitle')}\n\n ## ${t('introduction')}\n\n\n ## ${t('mainContent')}\n \n ## ${t('conclusion')}\n `, - isCustom: false, - }, - { - id: 'default_2', - name: t('technicalDoc'), - content: `# ${t('technicalDocTitle')}\n \n ## ${t('overview')}\n \n ## ${t('requirements')}\n \n ## ${t('architecture')}\n \n ## ${t('implementation')}\n \n ## ${t('testing')}\n \n ## ${t('deployment')}\n \n ## ${t('maintenance')}\n `, - isCustom: false, - }, - { - id: 'default_3', - name: t('meetingMinutes'), - content: `# ${t('meetingMinutesTitle')}\n \n ## ${t('date')}: ${new Date().toLocaleDateString()}\n \n ## ${t('participants')}\n \n ## ${t('agenda')}\n \n ## ${t('discussions')}\n \n ## ${t('decisions')}\n \n ## ${t('actionItems')}\n \n ## ${t('nextMeeting')}\n `, - isCustom: false, - }, - ]; + const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState< + string[] + >([]); + const [similarityThreshold, setSimilarityThreshold] = useState(0.7); + const [keywordSimilarityWeight, setKeywordSimilarityWeight] = + useState(0.5); + const [modelTemperature, setModelTemperature] = useState(0.7); + const [knowledgeBases, setKnowledgeBases] = useState([]); + const [isLoadingKbs, setIsLoadingKbs] = useState(false); - // 加载和初始化模板 - useEffect(() => { - const loadTemplatesFromStorage = () => { - try { + const getInitialDefaultTemplateDefinitions = useCallback( + (): TemplateItem[] => [ + { + id: 'default_1_v4f', + name: t('defaultTemplate'), + content: `# ${t('defaultTemplateTitle')}\n ## ${t('introduction')}\n ## ${t('mainContent')}\n \n ## ${t('conclusion')}\n `, + isCustom: false, + }, + { + id: 'default_2_v4f', + name: t('technicalDoc'), + content: `# ${t('technicalDocTitle')}\n \n ## ${t('overview')}\n \n ## ${t('requirements')}\n \n ## ${t('architecture')}\n \n ## ${t('implementation')}\n \n ## ${t('testing')}\n \n ## ${t('deployment')}\n \n ## ${t('maintenance')}\n `, + isCustom: false, + }, + { + id: 'default_3_v4f', + name: t('meetingMinutes'), + content: `# ${t('meetingMinutesTitle')}\n \n ## ${t('date')}: ${new Date().toLocaleDateString()}\n \n ## ${t('participants')}\n \n ## ${t('agenda')}\n \n ## ${t('discussions')}\n \n ## ${t('decisions')}\n \n ## ${t('actionItems')}\n \n ## ${t('nextMeeting')}\n `, + isCustom: false, + }, + ], + [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, ); - if (savedTemplatesString) { - const loadedTemplates = JSON.parse( - savedTemplatesString, - ) as TemplateItem[]; - setTemplates(loadedTemplates); - if (loadedTemplates.length > 0) { - // 默认选中第一个模板,或之前选中的模板(如果需要更复杂的选中逻辑) - const currentSelected = selectedTemplate || loadedTemplates[0].id; - const foundSelected = loadedTemplates.find( - (t) => t.id === currentSelected, - ); - if (foundSelected) { - setSelectedTemplate(foundSelected.id); - setContent(foundSelected.content); - } else if (loadedTemplates.length > 0) { - setSelectedTemplate(loadedTemplates[0].id); - setContent(loadedTemplates[0].content); - } else { - setContent(''); // 没有模板了 - setSelectedTemplate(''); - } - } else { - setContent(''); // 没有模板则清空内容 - setSelectedTemplate(''); - } - } else { - // localStorage中没有模板,初始化默认模板并存储 - const initialDefaults = getInitialDefaultTemplates(); - setTemplates(initialDefaults); + currentTemplates = savedTemplatesString + ? JSON.parse(savedTemplatesString) + : getInitialDefaultTemplateDefinitions(); + if (!savedTemplatesString) { localStorage.setItem( LOCAL_STORAGE_TEMPLATES_KEY, - JSON.stringify(initialDefaults), + JSON.stringify(currentTemplates), ); - if (initialDefaults.length > 0) { - setSelectedTemplate(initialDefaults[0].id); - setContent(initialDefaults[0].content); - } } - } catch (error) { - console.error('加载模板失败:', error); - // 加载失败,可以尝试使用硬编码的默认值作为后备 - const fallbackDefaults = getInitialDefaultTemplates(); - setTemplates(fallbackDefaults); - if (fallbackDefaults.length > 0) { - setSelectedTemplate(fallbackDefaults[0].id); - setContent(fallbackDefaults[0].content); + } 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(''); } } - }; - - loadTemplatesFromStorage(); + } 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); + } + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [t]); // t 作为依赖,确保语言切换时默认模板名称能正确初始化 + }, [t, getInitialDefaultTemplateDefinitions]); + + useEffect(() => { + loadOrInitializeTemplates(); + }, [loadOrInitializeTemplates]); + + useEffect(() => { + const fetchKbs = async () => { + const authorization = localStorage.getItem('Authorization'); + if (!authorization) { + setKnowledgeBases([]); + return; + } + setIsLoadingKbs(true); + try { + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + const mockKbs: KnowledgeBaseItem[] = [ + { + id: 'kb_default', + name: t('defaultKnowledgeBase', { defaultValue: '默认知识库' }), + }, + { + id: 'kb_tech', + name: t('technicalDocsKnowledgeBase', { + defaultValue: '技术文档知识库', + }), + }, + { + id: 'kb_product', + name: t('productInfoKnowledgeBase', { + defaultValue: '产品信息知识库', + }), + }, + { + id: 'kb_marketing', + name: t('marketingMaterialsKB', { defaultValue: '市场营销材料库' }), + }, + { + id: 'kb_legal', + name: t('legalDocumentsKB', { defaultValue: '法律文件库' }), + }, + ]; + setKnowledgeBases(mockKbs); + } catch (error) { + console.error('获取知识库失败:', error); + message.error(t('fetchKnowledgeBaseFailed')); + setKnowledgeBases([]); + } finally { + setIsLoadingKbs(false); + } + }; + fetchKbs(); + }, [t]); - // 获取对话列表和加载草稿 useEffect(() => { const fetchDialogs = async () => { - // ... (省略,与模板无关) try { const authorization = localStorage.getItem('Authorization'); - if (!authorization) { - console.log('未登录,跳过获取对话列表'); - return; - } + if (!authorization) return; const response = await axios.get('/v1/dialog', { headers: { authorization }, }); - if (response.data?.data?.length > 0) { + if (response.data?.data?.length > 0) setDialogId(response.data.data[0].id); - } } catch (error) { console.error('获取对话列表失败:', error); } }; - const loadDraftContent = () => { try { const draftContent = localStorage.getItem('writeDraftContent'); - // 只有当 templates 加载完毕后,且当前 content 仍然为空时,才加载草稿 - // 避免草稿覆盖刚从选中模板加载的内容 - if (draftContent && !content && templates.length > 0) { - // 或者,我们可以优先草稿,如果草稿存在,就不从选中模板加载内容 - // 当前逻辑是:模板内容优先,如果模板内容为空,则尝试加载草_稿 - // 修改为:如果选了模板,就用模板内容;如果没选模板(比如全删了),再看草稿 - const currentSelectedTemplate = templates.find( - (temp) => temp.id === selectedTemplate, - ); - if (!currentSelectedTemplate && draftContent) { - setContent(draftContent); - } else if ( - currentSelectedTemplate && - !currentSelectedTemplate.content && - draftContent - ) { - // 如果选中的模板内容为空,也加载草稿 - setContent(draftContent); - } - // 如果只想在完全没有模板内容被设置时加载草稿: - // if (draftContent && !content) setContent(draftContent); - } else if ( + if ( draftContent && - !selectedTemplate && - templates.length === 0 + !content && + (!selectedTemplate || + templates.find((t) => t.id === selectedTemplate)?.content === '') ) { - // 如果没有模板,也没有选中的模板,则加载草稿 setContent(draftContent); } } catch (error) { console.error('加载暂存内容失败:', error); } }; - fetchDialogs(); - // loadTemplatesFromStorage 已经在上面的useEffect中处理 - // 草稿加载应该在模板和其内容设置之后,或者有更明确的优先级 - if ( - templates.length > 0 || - localStorage.getItem(LOCAL_STORAGE_TEMPLATES_KEY) - ) { - // 确保模板已尝试加载 + if (localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY) === 'true') { loadDraftContent(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [templates, selectedTemplate]); // 当模板或选中模板变化时,重新评估是否加载草稿 + }, [content, selectedTemplate, templates]); - // 自动保存内容到localStorage useEffect(() => { - const timer = setTimeout(() => { - try { - localStorage.setItem('writeDraftContent', content); - } catch (error) { - console.error('保存暂存内容失败:', error); - } - }, 1000); - + const timer = setTimeout( + () => localStorage.setItem('writeDraftContent', content), + 1000, + ); return () => clearTimeout(timer); }, [content]); - // 处理模板选择 const handleTemplateSelect = (templateId: string) => { setSelectedTemplate(templateId); - const selectedTemplateItem = templates.find( - (item) => item.id === templateId, - ); - if (selectedTemplateItem) { - setContent(selectedTemplateItem.content); - } + const item = templates.find((t) => t.id === templateId); + if (item) setContent(item.content); }; - // 保存自定义模板 const handleSaveTemplate = () => { if (!templateName.trim()) { message.warning(t('enterTemplateName')); @@ -240,14 +279,12 @@ const Write = () => { message.warning(t('enterTemplateContent')); return; } - const newTemplate: TemplateItem = { id: `custom_${Date.now()}`, name: templateName, - content: content, + content, isCustom: true, }; - try { const updatedTemplates = [...templates, newTemplate]; setTemplates(updatedTemplates); @@ -255,18 +292,16 @@ const Write = () => { LOCAL_STORAGE_TEMPLATES_KEY, JSON.stringify(updatedTemplates), ); - message.success(t('templateSavedSuccess')); setIsTemplateModalVisible(false); setTemplateName(''); - setSelectedTemplate(newTemplate.id); // 选中新保存的模板 + setSelectedTemplate(newTemplate.id); } catch (error) { console.error('保存模板失败:', error); message.error(t('templateSavedFailed')); } }; - // 删除模板 (包括默认模板) const handleDeleteTemplate = (templateId: string) => { try { const updatedTemplates = templates.filter((t) => t.id !== templateId); @@ -275,15 +310,11 @@ const Write = () => { LOCAL_STORAGE_TEMPLATES_KEY, JSON.stringify(updatedTemplates), ); - if (selectedTemplate === templateId) { - // 如果删除的是当前选中的模板 if (updatedTemplates.length > 0) { - // 切换到列表中的第一个模板 setSelectedTemplate(updatedTemplates[0].id); setContent(updatedTemplates[0].content); } else { - // 如果没有模板了,清空内容和选择 setSelectedTemplate(''); setContent(''); } @@ -295,127 +326,386 @@ const Write = () => { } }; - // 实现保存为Word功能 + const handleAiQuestionSubmit = async ( + e: React.KeyboardEvent, + ) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (!aiQuestion.trim()) { + message.warning(t('enterYourQuestion')); + return; + } + if (!dialogId) { + message.error(t('noDialogFound')); + return; + } + setIsAiLoading(true); + const initialCursorPos = cursorPosition; + const originalContent = content; + let beforeCursor = '', + afterCursor = ''; + if (initialCursorPos !== null && showCursorIndicator) { + beforeCursor = originalContent.substring(0, initialCursorPos); + afterCursor = originalContent.substring(initialCursorPos); + } + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + aiAssistantConfig.api.timeout || 30000, + ); + try { + const authorization = localStorage.getItem('Authorization'); + if (!authorization) { + message.error(t('loginRequiredError')); + setIsAiLoading(false); + return; + } + const conversationId = + Math.random().toString(36).substring(2) + Date.now().toString(36); + await axios.post( + 'v1/conversation/set', + { + dialog_id: dialogId, + name: '文档撰写对话', + is_new: true, + conversation_id: conversationId, + message: [{ role: 'assistant', content: '新对话' }], + }, + { headers: { authorization }, signal: controller.signal }, + ); + const combinedQuestion = `${aiQuestion}\n\n${t('currentDocumentContextLabel')}:\n${originalContent}`; + let lastReceivedContent = ''; + const response = await axios.post( + '/v1/conversation/completion', + { + conversation_id: conversationId, + messages: [{ role: 'user', content: combinedQuestion }], + knowledge_base_ids: + selectedKnowledgeBases.length > 0 + ? selectedKnowledgeBases + : undefined, + similarity_threshold: similarityThreshold, + keyword_similarity_weight: keywordSimilarityWeight, + temperature: modelTemperature, + }, + { + timeout: aiAssistantConfig.api.timeout, + headers: { authorization }, + signal: controller.signal, + }, + ); + if (response.data) { + const lines = response.data + .split('\n') + .filter((line: string) => line.trim()); + for (let i = 0; i < lines.length; i++) { + try { + const jsonStr = lines[i].replace('data:', '').trim(); + const jsonData = JSON.parse(jsonStr); + if (jsonData.code === 0 && jsonData.data?.answer) { + const answerChunk = jsonData.data.answer; + const cleanedAnswerChunk = answerChunk + .replace(/[\s\S]*?<\/think>/g, '') + .trim(); + const hasUnclosedThink = + cleanedAnswerChunk.includes('') && + (!cleanedAnswerChunk.includes('') || + cleanedAnswerChunk.indexOf('') > + cleanedAnswerChunk.lastIndexOf('')); + if (cleanedAnswerChunk && !hasUnclosedThink) { + const incrementalContent = cleanedAnswerChunk.substring( + lastReceivedContent.length, + ); + if (incrementalContent) { + lastReceivedContent = cleanedAnswerChunk; + let newFullContent, + newCursorPosAfterInsertion = cursorPosition; + if (initialCursorPos !== null && showCursorIndicator) { + newFullContent = + beforeCursor + cleanedAnswerChunk + afterCursor; + newCursorPosAfterInsertion = + initialCursorPos + cleanedAnswerChunk.length; + } else { + newFullContent = originalContent + cleanedAnswerChunk; + newCursorPosAfterInsertion = newFullContent.length; + } + setContent(newFullContent); + setCursorPosition(newCursorPosAfterInsertion); + setTimeout(() => { + if (textAreaRef.current) { + textAreaRef.current.focus(); + textAreaRef.current.setSelectionRange( + newCursorPosAfterInsertion!, + newCursorPosAfterInsertion!, + ); + } + }, 0); + } + } + } + } catch (parseErr) { + console.error('解析单行数据失败:', parseErr); + } + if (i < lines.length - 1) + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + } + await axios.post( + '/v1/conversation/rm', + { conversation_ids: [conversationId], dialog_id: dialogId }, + { headers: { authorization } }, + ); + } 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')); + } + } finally { + clearTimeout(timeoutId); + setIsAiLoading(false); + setAiQuestion(''); + if (textAreaRef.current) textAreaRef.current.focus(); + } + } + }; + const handleSave = () => { const selectedTemplateItem = templates.find( (item) => item.id === selectedTemplate, ); const baseName = selectedTemplateItem ? selectedTemplateItem.name - : t('document'); // 如果没有选中模板,使用通用名称 - + : t('document', { defaultValue: '文档' }); const today = new Date(); const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`; - const fileName = `${baseName}${dateStr}.docx`; + const fileName = `${baseName}_${dateStr}.docx`; - const tokens = marked.lexer(content); - const paragraphs: Paragraph[] = []; + if (!content.trim()) { + message.warning( + t('emptyContentCannotExport', { defaultValue: '内容为空,无法导出' }), + ); + return; + } - tokens.forEach((token) => { - if (token.type === 'heading') { - const headingToken = token as any; - let headingType: - | 'Heading1' - | 'Heading2' - | 'Heading3' - | 'Heading4' - | 'Heading5' - | 'Heading6' - | undefined; - switch (headingToken.depth) { - case 1: - headingType = 'Heading1'; - break; - case 2: - headingType = 'Heading2'; - break; - // ... (其他 case) - default: - headingType = 'Heading1'; - } - paragraphs.push( - new Paragraph({ - children: [ - new TextRun({ - text: headingToken.text, - size: 28 - headingToken.depth * 2, - }), - ], - heading: headingType, - spacing: { after: 200 - headingToken.depth * 40 }, - }), - ); - } else if (token.type === 'paragraph') { - const paraToken = token as any; - paragraphs.push( - new Paragraph({ - children: [new TextRun({ text: paraToken.text })], - spacing: { after: 80 }, - }), - ); - } else if (token.type === 'space') { - paragraphs.push(new Paragraph({})); + try { + const tokens = marked.lexer(content) as Token[]; + const paragraphs: Paragraph[] = []; + + function parseTokensToRuns( + inlineTokens: Tokens.Generic[] | undefined, + ): TextRun[] { + const runs: TextRun[] = []; + if (!inlineTokens) return runs; + + inlineTokens.forEach((token) => { + if (token.type === 'text') { + runs.push(new TextRun(token.raw)); // Use raw for exact text + } else if ( + token.type === 'strong' && + 'text' in token && + typeof token.text === 'string' + ) { + runs.push(new TextRun({ text: token.text, bold: true })); + } else if ( + token.type === 'em' && + 'text' in token && + typeof token.text === 'string' + ) { + runs.push(new TextRun({ text: token.text, italics: true })); + } else if ( + token.type === 'codespan' && + 'text' in token && + typeof token.text === 'string' + ) { + runs.push(new TextRun({ text: token.text, style: 'Consolas' })); + } else if ( + token.type === 'del' && + 'text' in token && + typeof token.text === 'string' + ) { + runs.push(new TextRun({ text: token.text, strike: true })); + } else if ( + token.type === 'link' && + 'text' in token && + typeof token.text === 'string' && + 'href' in token && + typeof token.href === 'string' + ) { + runs.push(new TextRun({ text: token.text, style: 'Hyperlink' })); + } else if ('raw' in token) { + runs.push(new TextRun(token.raw)); + } + }); + return runs; } - }); - const doc = new Document({ sections: [{ children: paragraphs }] }); - Packer.toBlob(doc).then((blob) => { - saveAs(blob, fileName); - }); + tokens.forEach((token) => { + if (token.type === 'heading') { + const headingToken = token as MarkedHeadingToken; + let docxHeadingLevel: (typeof HeadingLevel)[keyof typeof HeadingLevel]; + switch (headingToken.depth) { + case 1: + docxHeadingLevel = HeadingLevel.HEADING_1; + break; + case 2: + docxHeadingLevel = HeadingLevel.HEADING_2; + break; + case 3: + docxHeadingLevel = HeadingLevel.HEADING_3; + break; + case 4: + docxHeadingLevel = HeadingLevel.HEADING_4; + break; + case 5: + docxHeadingLevel = HeadingLevel.HEADING_5; + break; + case 6: + docxHeadingLevel = HeadingLevel.HEADING_6; + break; + default: + docxHeadingLevel = HeadingLevel.HEADING_1; + } + paragraphs.push( + new Paragraph({ + children: parseTokensToRuns( + headingToken.tokens || + ([ + { + type: 'text', + raw: headingToken.text, + text: headingToken.text, + }, + ] as any), + ), + heading: docxHeadingLevel, + spacing: { + after: 200 - headingToken.depth * 20, + before: headingToken.depth === 1 ? 0 : 100, + }, + }), + ); + } else if (token.type === 'paragraph') { + const paraToken = token as MarkedParagraphToken; + const runs = parseTokensToRuns(paraToken.tokens); + paragraphs.push( + new Paragraph({ + children: runs.length > 0 ? runs : [new TextRun('')], + spacing: { after: 120 }, + }), + ); + } else if (token.type === 'list') { + const listToken = token as MarkedListToken; + listToken.items.forEach((listItem: MarkedListItem) => { + const itemRuns = parseTokensToRuns(listItem.tokens); + paragraphs.push( + new Paragraph({ + children: itemRuns.length > 0 ? itemRuns : [new TextRun('')], + bullet: listToken.ordered ? undefined : { level: 0 }, + numbering: listToken.ordered + ? { reference: 'default-numbering', level: 0 } + : undefined, + }), + ); + }); + paragraphs.push(new Paragraph({ spacing: { after: 80 } })); + } else if (token.type === 'space') { + const spaceToken = token as MarkedSpaceToken; + const newlines = (spaceToken.raw.match(/\n/g) || []).length; + if (newlines > 1) { + for (let i = 0; i < newlines; i++) + paragraphs.push(new Paragraph({})); + } else { + paragraphs.push(new Paragraph({ spacing: { after: 80 } })); + } + } + }); + + if (paragraphs.length === 0 && content.trim()) { + paragraphs.push(new Paragraph({ children: [new TextRun(content)] })); + } + + const doc = new Document({ + numbering: { + config: [ + { + reference: 'default-numbering', + levels: [ + { + level: 0, + format: 'decimal', + text: '%1.', + alignment: AlignmentType.LEFT, + }, + ], + }, + ], + }, + sections: [{ properties: {}, children: paragraphs }], + }); + + Packer.toBlob(doc) + .then((blob) => { + saveAs(blob, fileName); + message.success( + t('exportSuccess', { defaultValue: '文档导出成功!' }), + ); + }) + .catch((packError) => { + console.error('Error packing document: ', packError); + message.error( + t('exportFailedError', { + defaultValue: '文档导出失败,请检查控制台日志。', + }), + ); + }); + } catch (error) { + console.error('Error generating Word document: ', error); + message.error( + t('exportProcessError', { defaultValue: '处理文档导出时发生错误。' }), + ); + } }; - // ... renderEditor, renderPreview, renderContent, handleAiQuestionSubmit (这些函数基本不变) const renderEditor = () => ( -
- setContent(e.target.value)} - onClick={(e) => { - const target = e.target as HTMLTextAreaElement; - setCursorPosition(target.selectionStart); - setShowCursorIndicator(true); - }} - onKeyUp={(e) => { - const target = e.target as HTMLTextAreaElement; - setCursorPosition(target.selectionStart); - setShowCursorIndicator(true); - }} - placeholder={t('writePlaceholder')} - autoSize={false} - /> - {showCursorIndicator && cursorPosition !== null && ( -
- 已锁定插入位置 -
- )} -
+ setContent(e.target.value)} + onClick={(e) => { + const target = e.target as HTMLTextAreaElement; + setCursorPosition(target.selectionStart); + setShowCursorIndicator(true); + }} + onKeyUp={(e) => { + const target = e.target as HTMLTextAreaElement; + setCursorPosition(target.selectionStart); + setShowCursorIndicator(true); + }} + placeholder={t('writePlaceholder')} + autoSize={false} + /> ); - const renderPreview = () => (
{ const renderContent = () => { switch (viewMode) { case 'edit': - return renderEditor(); + return ( +
{renderEditor()}
+ ); case 'preview': - return renderPreview(); + return ( +
{renderPreview()}
+ ); case 'split': default: return ( - +
{renderEditor()}
-
+
{renderPreview()}
@@ -454,281 +751,226 @@ const Write = () => { } }; - const handleAiQuestionSubmit = async ( - e: React.KeyboardEvent, - ) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - - if (!aiQuestion.trim()) { - message.warning('请输入问题'); - return; - } - - if (!dialogId) { - message.error('未找到可用的对话'); - return; - } - - setIsAiLoading(true); - - const initialCursorPos = cursorPosition; - const initialContent = content; - let beforeCursor = ''; - let afterCursor = ''; - - if (initialCursorPos !== null && showCursorIndicator) { - beforeCursor = initialContent.substring(0, initialCursorPos); - afterCursor = initialContent.substring(initialCursorPos); - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, aiAssistantConfig.api.timeout || 30000); - - try { - const authorization = localStorage.getItem('Authorization'); - if (!authorization) { - message.error('未登录,请先登录'); - setIsAiLoading(false); - return; - } - - const conversationId = - Math.random().toString(36).substring(2) + Date.now().toString(36); - - try { - const createSessionResponse = await axios.post( - 'v1/conversation/set', - { - dialog_id: dialogId, - name: '文档撰写对话', - is_new: true, - conversation_id: conversationId, - message: [{ role: 'assistant', content: '新对话' }], - }, - { headers: { authorization }, signal: controller.signal }, - ); - - if (!createSessionResponse.data?.data?.id) { - message.error('创建会话失败'); - setIsAiLoading(false); - return; - } - } catch (error) { - console.error('创建会话失败:', error); - message.error('创建会话失败,请重试'); - setIsAiLoading(false); - return; - } - - const combinedQuestion = `${aiQuestion}\n\n当前文档内容:\n${content}`; - console.log('发送问题到 AI 助手:', aiQuestion); - let lastContent = ''; - - try { - const response = await axios.post( - '/v1/conversation/completion', - { - conversation_id: conversationId, - messages: [{ role: 'user', content: combinedQuestion }], - }, - { - timeout: aiAssistantConfig.api.timeout, - headers: { authorization }, - signal: controller.signal, - }, - ); - - if (response.data) { - const lines = response.data - .split('\n') - .filter((line: string) => line.trim()); - - for (let i = 0; i < lines.length; i++) { - try { - const jsonStr = lines[i].replace('data:', '').trim(); - const jsonData = JSON.parse(jsonStr); - - if (jsonData.code === 0 && jsonData.data?.answer) { - const answer = jsonData.data.answer; - const cleanedAnswer = answer - .replace(/[\s\S]*?<\/think>/g, '') - .trim(); - const hasUnclosedThink = - cleanedAnswer.includes('') && - (!cleanedAnswer.includes('') || - cleanedAnswer.indexOf('') > - cleanedAnswer.lastIndexOf('')); - - if (cleanedAnswer && !hasUnclosedThink) { - const newContentChunk = cleanedAnswer; - const incrementalContent = newContentChunk.substring( - lastContent.length, - ); - - if (incrementalContent) { - lastContent = newContentChunk; - - if (initialCursorPos !== null && showCursorIndicator) { - const currentFullContent = - beforeCursor + newContentChunk + afterCursor; - setContent(currentFullContent); - const newPosition = - initialCursorPos + newContentChunk.length; - setCursorPosition(newPosition); - if (textAreaRef.current) { - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.focus(); - textAreaRef.current.setSelectionRange( - newPosition, - newPosition, - ); - } - }, 0); - } - } else { - setContent(initialContent + newContentChunk); - } - } - } - } - } catch (parseErr) { - console.error('解析单行数据失败:', parseErr); - } - if (i < lines.length - 1) { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - } - } - } - } catch (error: any) { - console.error('获取 AI 回答失败:', error); - if (error.code === 'ECONNABORTED' || error.name === 'AbortError') { - message.error('AI 助手响应超时,请稍后重试'); - } else { - message.error('获取 AI 回答失败,请重试'); - } - } finally { - clearTimeout(timeoutId); - } - - try { - await axios.post( - '/v1/conversation/rm', - { - conversation_ids: [conversationId], - dialog_id: dialogId, - }, - { headers: { authorization } }, - ); - console.log('临时会话已删除:', conversationId); - if (textAreaRef.current) { - textAreaRef.current.focus(); - if (cursorPosition !== null && showCursorIndicator) { - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.setSelectionRange( - cursorPosition, - cursorPosition, - ); - } - }, 0); - } - } - } catch (rmErr) { - console.error('删除临时会话失败:', rmErr); - } - } catch (err) { - console.error('AI 助手处理失败:', err); - message.error('AI 助手处理失败,请重试'); - } finally { - setIsAiLoading(false); - setAiQuestion(''); - } - } - }; - return ( - + - - - {t('templateList')} - - -
- } - dataSource={templates} // 数据源现在是动态从localStorage加载的 - locale={{ emptyText: t('noTemplatesAvailable') }} - renderItem={(item) => ( - handleDeleteTemplate(item.id)} - okText={t('confirm')} - cancelText={t('cancel')} - > - - , - ]} - style={{ - cursor: 'pointer', - background: selectedTemplate === item.id ? '#f0f7ff' : 'none', - borderRadius: 8, - paddingLeft: 12, - }} - onClick={() => handleTemplateSelect(item.id)} +
+ + {t('templateList')} + + + + +
+ ( + handleDeleteTemplate(item.id)} + okText={t('confirm')} + cancelText={t('cancel')} + > +
+
+ +
+ + {t('modelConfigurationTitle', { defaultValue: '模型配置' })} + +
+ + + + + `${value?.toFixed(2)}` }} + /> + + + `${value?.toFixed(2)}` }} + /> + + + `${value?.toFixed(2)}` }} + /> + +
+
- - + + -

{t('writeDocument')}

+ + {t('writeDocument')} +
{renderContent()} @@ -768,33 +1016,29 @@ const Write = () => { -
- {isAiLoading && ( -
-
{t('aiLoadingMessage')}
-
- )} - setAiQuestion(e.target.value)} - onKeyDown={handleAiQuestionSubmit} - disabled={isAiLoading} - /> -
+ {isAiLoading && ( +
+ {t('aiLoadingMessage')}... +
+ )} + setAiQuestion(e.target.value)} + onKeyDown={handleAiQuestionSubmit} + disabled={isAiLoading} + />
- { okText={t('saveTemplate')} cancelText={t('cancel')} > - {/* (Modal 内容不变) */} -
- - setTemplateName(e.target.value)} - style={{ marginTop: 8 }} - /> -
-
- - -
+
+ + setTemplateName(e.target.value)} + /> + + + + +
);