feat(write): 文档撰写模式,支持编辑内容的自动保存和加载

- 移除未使用的 aiAssistantConfig 文件
- 实现草稿自动保存和加载功能
This commit is contained in:
zstar 2025-06-07 17:16:36 +08:00
parent 70647d36d5
commit 3a1bf079fc
4 changed files with 120 additions and 204 deletions

View File

@ -51,7 +51,7 @@ MYSQL_PORT=5455
# The hostname where the MinIO service is exposed
MINIO_HOST=minio
# minio上传文件时的ip地址如需公网访问可修改为公网ip地址
MINIO_VISIT_HOST=host.docker.internal
MINIO_VISIT_HOST=localhost
# The port used to expose the MinIO console interface to the host machine,
# allowing EXTERNAL access to the web-based console running inside the Docker container.
MINIO_CONSOLE_PORT=9001

View File

@ -6,7 +6,6 @@
.messageContainer {
overflow-y: auto;
padding-right: 24px;
h1,
h2,
h3,

View File

@ -1,20 +0,0 @@
export const aiAssistantConfig = {
// 模型配置
model: {
modelName: "deepseek-r1:1.5b", // 默认模型
temperature: 0.7, // 温度参数
topP: 0.9, // Top-P 参数
maxTokens: 1024, // 最大生成 token 数
},
// API 配置
api: {
// 修改为正确的 API 端点格式
chatEndpoint: "/api/v1/chats", // 创建聊天会话的端点
completionEndpoint: "/api/v1/chats/{chat_id}/completions", // 聊天完成的端点
timeout: 300000, // 请求超时时间(ms)
},
// 默认系统提示词
systemPrompt: "你是一个专业的写作助手,可以帮助用户改进文档、提供写作建议和回答相关问题。",
};

View File

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