import HightLightMarkdown from '@/components/highlight-markdown'; import { useTranslate } from '@/hooks/common-hooks'; import { aiAssistantConfig } from '@/pages/write/ai-assistant-config'; import { Button, Card, Flex, Input, Layout, List, Space, Tabs, message, } from 'antd'; import axios from 'axios'; import { Document, Packer, Paragraph, TextRun } from 'docx'; import { saveAs } from 'file-saver'; import { marked } from 'marked'; import { useEffect, useRef, useState } from 'react'; const { Sider, Content } = Layout; const { TabPane } = Tabs; interface TemplateItem { id: string; name: string; content: string; } const Write = () => { const { t } = useTranslate('write'); const [content, setContent] = useState(''); const [aiQuestion, setAiQuestion] = useState(''); const [isAiLoading, setIsAiLoading] = useState(false); const [dialogId, setDialogId] = useState(''); 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 [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>( 'split', ); // 在组件加载时获取对话列表 useEffect(() => { const fetchDialogList = async () => { try { const authorization = localStorage.getItem('Authorization'); if (!authorization) return; const response = await axios.get('/v1/dialog/list', { headers: { authorization: authorization, }, }); if (response.data?.data?.length > 0) { // 使用第一个对话的ID setDialogId(response.data.data[0].id); } } catch (error) { console.error('获取对话列表失败:', error); } }; fetchDialogList(); }, []); // 处理模板选择 const handleTemplateSelect = (templateId: string) => { setSelectedTemplate(templateId); // 查找选中的模板 const selectedTemplateItem = templates.find( (item) => item.id === templateId, ); if (selectedTemplateItem) { // 填充模板内容 setContent(selectedTemplateItem.content); } }; // 实现保存为Word功能 const handleSave = () => { // 获取当前选中的模板名称 const selectedTemplateItem = templates.find( (item) => item.id === selectedTemplate, ); if (!selectedTemplateItem) return; // 生成文件名:模板名+当前日期,例如:学术论文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`; // 使用 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 问题 let headingType: | 'Heading1' | 'Heading2' | 'Heading3' | 'Heading4' | 'Heading5' | 'Heading6' | undefined; // 根据标题级别设置正确的标题类型 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({})); } // 可以根据需要添加对其他类型的处理,如列表、表格等 }); // 将所有段落添加到文档中 const doc = new Document({ sections: [ { children: paragraphs, }, ], }); // 生成并下载 Word 文档 Packer.toBlob(doc).then((blob) => { saveAs(blob, fileName); }); }; // 渲染编辑器部分 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 renderPreview = () => (
{content || t('previewPlaceholder')}
); // 根据视图模式渲染内容 const renderContent = () => { switch (viewMode) { case 'edit': return renderEditor(); case 'preview': return renderPreview(); case 'split': default: return (
{renderEditor()}
{renderPreview()}
); } }; // 处理 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)} > {item.name} )} />

{t('writeDocument')}

{renderContent()}
{isAiLoading && (
AI 助手正在思考中...
)} setAiQuestion(e.target.value)} onKeyDown={handleAiQuestionSubmit} disabled={isAiLoading} />
); }; export default Write;