feat(write): 文档撰写模式,支持编辑内容的自动保存和加载
- 移除未使用的 aiAssistantConfig 文件 - 实现草稿自动保存和加载功能
This commit is contained in:
parent
70647d36d5
commit
3a1bf079fc
|
@ -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
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
.messageContainer {
|
||||
overflow-y: auto;
|
||||
padding-right: 24px;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
|
|
|
@ -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: "你是一个专业的写作助手,可以帮助用户改进文档、提供写作建议和回答相关问题。",
|
||||
};
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue