diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index d8b6384..a4e9854 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -548,12 +548,28 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 meetingMinutes: '会议纪要', select: '选择', aiAssistant: 'AI 助手', - askAI: '输入您的问题与AI交流...', + askAI: '输入您的问题', edit: '编辑模式', split: '分屏模式', preview: '预览模式', previewPlaceholder: '请输入文档内容...', - + saveCurrentAsTemplate: '保存当前内容为模板', + delete: '删除', + customTemplateMarker: '自定义模板', + confirmDeleteTemplate: '确认删除模板?', + cancel: '取消', + confirm: '确认', + saveAsCustomTemplateTitle: '保存为自定义模板', + templateNameLabel: '模板名称', + templateContentPreviewLabel: '模板内容预览', + templateNameMessage: '模板名称是必填项', + templateNamePlaceholder: '请输入模板名称', + saveTemplate: '保存', + templateDeletedSuccess: '模板删除成功', + templateDeletedFailed: '模板删除失败', + templateSavedSuccess: '模板保存成功', + templateSavedFailed: '模板保存失败', + // 模板标题和内容 defaultTemplateTitle: '申报书文档标题', introduction: '1. 引言', @@ -575,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.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..a8347c2 100644 --- a/web/src/pages/write/index.tsx +++ b/web/src/pages/write/index.tsx @@ -1,31 +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, - Space, - Tabs, 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 { TabPane } = Tabs; +const { Option } = Select; + +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; } +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'); @@ -36,262 +69,643 @@ 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')} - - - ## ${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 [templates, setTemplates] = useState([]); + const [isTemplateModalVisible, setIsTemplateModalVisible] = useState(false); + const [templateName, setTemplateName] = useState(''); + const [selectedTemplate, setSelectedTemplate] = useState(''); const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>( 'split', ); - // 在组件加载时获取对话列表 + 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); + + 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, + ); + currentTemplates = savedTemplatesString + ? JSON.parse(savedTemplatesString) + : getInitialDefaultTemplateDefinitions(); + if (!savedTemplatesString) { + localStorage.setItem( + LOCAL_STORAGE_TEMPLATES_KEY, + JSON.stringify(currentTemplates), + ); + } + } 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); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t, getInitialDefaultTemplateDefinitions]); + useEffect(() => { - const fetchDialogList = async () => { + 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) return; - - const response = await axios.get('/v1/dialog/list', { - headers: { - authorization: authorization, - }, + const response = await axios.get('/v1/dialog', { + headers: { authorization }, }); - - if (response.data?.data?.length > 0) { - // 使用第一个对话的ID + 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'); + if ( + draftContent && + !content && + (!selectedTemplate || + templates.find((t) => t.id === selectedTemplate)?.content === '') + ) { + setContent(draftContent); + } + } catch (error) { + console.error('加载暂存内容失败:', error); + } + }; + fetchDialogs(); + if (localStorage.getItem(LOCAL_STORAGE_INIT_FLAG_KEY) === 'true') { + loadDraftContent(); + } + }, [content, selectedTemplate, templates]); - fetchDialogList(); - }, []); + useEffect(() => { + 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')); + return; + } + if (!content.trim()) { + message.warning(t('enterTemplateContent')); + return; + } + const newTemplate: TemplateItem = { + id: `custom_${Date.now()}`, + name: templateName, + 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')); + } + }; + + 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(); + } } }; - // 实现保存为Word功能 const handleSave = () => { - // 获取当前选中的模板名称 const selectedTemplateItem = templates.find( (item) => item.id === selectedTemplate, ); - if (!selectedTemplateItem) return; - - // 生成文件名:模板名+当前日期,例如:学术论文20250319.docx + const baseName = selectedTemplateItem + ? selectedTemplateItem.name + : 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 = `${selectedTemplateItem.name}${dateStr}.docx`; + const fileName = `${baseName}_${dateStr}.docx`; - // 使用 marked 解析 Markdown 内容 - const tokens = marked.lexer(content); + if (!content.trim()) { + message.warning( + t('emptyContentCannotExport', { defaultValue: '内容为空,无法导出' }), + ); + return; + } - // 创建段落数组 - const paragraphs: Paragraph[] = []; + try { + const tokens = marked.lexer(content) as Token[]; + const paragraphs: Paragraph[] = []; - tokens.forEach((token) => { - if (token.type === 'heading') { - // 处理标题 - const headingToken = token as any; // 使用 any 类型绕过 marked.Tokens 问题 - let headingType: - | 'Heading1' - | 'Heading2' - | 'Heading3' - | 'Heading4' - | 'Heading5' - | 'Heading6' - | undefined; + function parseTokensToRuns( + inlineTokens: Tokens.Generic[] | undefined, + ): TextRun[] { + const runs: TextRun[] = []; + if (!inlineTokens) return runs; - // 根据标题级别设置正确的标题类型 - switch (headingToken.depth) { - case 1: - headingType = 'Heading1'; - break; - 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; - 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; // 使用 any 类型绕过 marked.Tokens 问题 - paragraphs.push( - new Paragraph({ - children: [ - new TextRun({ - text: paraToken.text, - }), - ], - spacing: { after: 80 }, - }), - ); - } else if (token.type === 'space') { - // 处理空行 - paragraphs.push(new Paragraph({})); + 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, + 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 }], + }); - // 生成并下载 Word 文档 - Packer.toBlob(doc).then((blob) => { - saveAs(blob, fileName); - }); + 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: '处理文档导出时发生错误。' }), + ); + } }; - // 渲染编辑器部分 - 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 && ( -
- 已锁定插入位置 -
- )} -
- ); - // 渲染预览部分 + 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} + /> + ); const renderPreview = () => (
{
); - // 根据视图模式渲染内容 const renderContent = () => { switch (viewMode) { case 'edit': - return renderEditor(); + return ( +
{renderEditor()}
+ ); case 'preview': - return renderPreview(); + return ( +
{renderPreview()}
+ ); case 'split': default: return ( - +
{renderEditor()}
-
+
{renderPreview()}
@@ -331,280 +751,226 @@ const Write = () => { } }; - // 处理 AI 助手问题提交 - 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; - } - - // 生成一个随机的会话ID - 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: 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: 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(); - const jsonData = JSON.parse(jsonStr); - - 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('') || - cleanedAnswer.indexOf('') > - cleanedAnswer.lastIndexOf('')); - - if (cleanedAnswer && !hasUnclosedThink) { - // 计算新增的内容部分 - const newContent = cleanedAnswer; - const incrementalContent = newContent.substring( - lastContent.length, - ); - - // 只有当有新增内容时才更新编辑器 - if (incrementalContent) { - // 更新上一次的内容记录 - lastContent = newContent; - - // 动态更新编辑器内容 - if (initialCursorPos !== null && showCursorIndicator) { - // 在光标位置动态插入内容 - setContent(beforeCursor + newContent + afterCursor); - - // 更新光标位置到插入内容之后 - const newPosition = - initialCursorPos + newContent.length; - setCursorPosition(newPosition); - } else { - // 如果没有光标位置,则追加到末尾 - setContent(initialContent + newContent); - } - } - } - } - } catch (parseErr) { - console.error('解析单行数据失败:', parseErr); - // 继续处理下一行,不中断整个流程 - } - - // 添加一个小延迟,让UI有时间更新 - 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: authorization, - }, - }, - ); - console.log('临时会话已删除:', conversationId); - - // 处理完成后,重新设置光标焦点 - if (textAreaRef.current) { - textAreaRef.current.focus(); - } - } catch (rmErr) { - console.error('删除临时会话失败:', rmErr); - // 删除会话失败不影响主流程 - } - } catch (err) { - console.error('AI 助手处理失败:', err); - message.error('AI 助手处理失败,请重试'); - } finally { - setIsAiLoading(false); - // 清空问题输入框 - setAiQuestion(''); - } - } - }; return ( - + - - {t('templateList')} -
- } - dataSource={templates} - renderItem={(item) => ( - handleTemplateSelect(item.id)} - >, - ]} - 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()} @@ -644,32 +1016,53 @@ const Write = () => { -
- {isAiLoading && ( -
-
AI 助手正在思考中...
-
- )} - setAiQuestion(e.target.value)} - onKeyDown={handleAiQuestionSubmit} - disabled={isAiLoading} - /> -
+ {isAiLoading && ( +
+ {t('aiLoadingMessage')}... +
+ )} + setAiQuestion(e.target.value)} + onKeyDown={handleAiQuestionSubmit} + disabled={isAiLoading} + />
+ { + setIsTemplateModalVisible(false); + setTemplateName(''); + }} + okText={t('saveTemplate')} + cancelText={t('cancel')} + > +
+ + setTemplateName(e.target.value)} + /> + + + + +
+
); };