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] =?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)} + /> + + + + +
);