diff --git a/api/apps/conversation_app.py b/api/apps/conversation_app.py index 416a738..cd047ee 100644 --- a/api/apps/conversation_app.py +++ b/api/apps/conversation_app.py @@ -262,7 +262,7 @@ def writechat(): def stream(): nonlocal req, uid try: - for ans in write_dialog(req["question"], req["kb_ids"], uid): + for ans in write_dialog(req["question"], req["kb_ids"], uid, req["similarity_threshold"], req["keyword_similarity_weight"], req["temperature"]): yield "data:" + json.dumps({"code": 0, "message": "", "data": ans}, ensure_ascii=False) + "\n\n" except Exception as e: yield "data:" + json.dumps({"code": 500, "message": str(e), "data": {"answer": "**ERROR**: " + str(e), "reference": []}}, ensure_ascii=False) + "\n\n" diff --git a/api/db/services/dialog_service.py b/api/db/services/dialog_service.py index 307b9ff..e9bf0f5 100644 --- a/api/db/services/dialog_service.py +++ b/api/db/services/dialog_service.py @@ -307,8 +307,7 @@ def chat(dialog, messages, stream=True, **kwargs): img_url = chunk.get("image_id") if img_url and img_url not in processed_image_urls: # 生成 Markdown 字符串,alt text 可以简单设为 "image" 或 chunk ID - alt_text = f"image_chunk_{chunk.get('chunk_id', i_int)}" - image_markdowns.append(f"\n![{alt_text}]({img_url})") + image_markdowns.append(f"\n![{img_url}]({img_url})") processed_image_urls.add(img_url) # 标记为已处理 idx = set([kbinfos["chunks"][int(i)]["doc_id"] for i in idx]) diff --git a/api/db/services/write_service.py b/api/db/services/write_service.py index 4e06b6a..b6243b6 100644 --- a/api/db/services/write_service.py +++ b/api/db/services/write_service.py @@ -1,3 +1,4 @@ +import time from api.db import LLMType, ParserType from api.db.services.knowledgebase_service import KnowledgebaseService from api.db.services.llm_service import LLMBundle @@ -6,7 +7,7 @@ from rag.app.tag import label_question from rag.prompts import kb_prompt -def write_dialog(question, kb_ids, tenant_id): +def write_dialog(question, kb_ids, tenant_id, similarity_threshold, keyword_similarity_weight, temperature): """ 处理用户搜索请求,从知识库中检索相关信息并生成回答 @@ -44,15 +45,16 @@ def write_dialog(question, kb_ids, tenant_id): # 获取所有知识库的租户ID并去重 tenant_ids = list(set([kb.tenant_id for kb in kbs])) # 调用检索器检索相关文档片段 - kbinfos = retriever.retrieval(question, embd_mdl, tenant_ids, kb_ids, 1, 12, 0.1, 0.3, aggs=False, rank_feature=label_question(question, kbs)) + kbinfos = retriever.retrieval(question, embd_mdl, tenant_ids, kb_ids, 1, 12, similarity_threshold, 1 - keyword_similarity_weight, aggs=False, rank_feature=label_question(question, kbs)) # 将检索结果格式化为提示词,并确保不超过模型最大token限制 knowledges = kb_prompt(kbinfos, max_tokens) + prompt = """ 角色:你是一个聪明的助手。 任务:总结知识库中的信息并回答用户的问题。 要求与限制: - 绝不要捏造内容,尤其是数字。 - - 如果知识库中的信息与用户问题无关,**只需回答:对不起,未提供相关信息。 + - 如果知识库中的信息与用户问题无关,只需回答:对不起,未提供相关信息。 - 使用Markdown格式进行回答。 - 使用用户提问所用的语言作答。 - 绝不要捏造内容,尤其是数字。 @@ -65,27 +67,24 @@ def write_dialog(question, kb_ids, tenant_id): """ % "\n".join(knowledges) msg = [{"role": "user", "content": question}] - # 生成完成后添加回答中的引用标记 - # def decorate_answer(answer): - # nonlocal knowledges, kbinfos, prompt - # answer, idx = retriever.insert_citations(answer, [ck["content_ltks"] for ck in kbinfos["chunks"]], [ck["vector"] for ck in kbinfos["chunks"]], embd_mdl, tkweight=0.7, vtweight=0.3) - # idx = set([kbinfos["chunks"][int(i)]["doc_id"] for i in idx]) - # recall_docs = [d for d in kbinfos["doc_aggs"] if d["doc_id"] in idx] - # if not recall_docs: - # recall_docs = kbinfos["doc_aggs"] - # kbinfos["doc_aggs"] = recall_docs - # refs = deepcopy(kbinfos) - # for c in refs["chunks"]: - # if c.get("vector"): - # del c["vector"] - - # if answer.lower().find("invalid key") >= 0 or answer.lower().find("invalid api") >= 0: - # answer += " Please set LLM API-Key in 'User Setting -> Model Providers -> API-Key'" - # refs["chunks"] = chunks_format(refs) - # return {"answer": answer, "reference": refs} - answer = "" - for ans in chat_mdl.chat_streamly(prompt, msg, {"temperature": 0.1}): + final_answer = "" + for ans in chat_mdl.chat_streamly(prompt, msg, {"temperature": temperature}): answer = ans + final_answer = answer yield {"answer": answer, "reference": {}} - # yield decorate_answer(answer) + + # 流式返回完毕后,追加图片 + image_markdowns = [] + image_urls = set() + for chunk in kbinfos["chunks"]: + img_url = chunk.get("image_id") + if img_url and img_url not in image_urls: + image_urls.add(img_url) + image_markdowns.append(f"\n![{img_url}]({img_url})") + + if image_markdowns: + final_answer += "".join(image_markdowns) + yield {"answer": final_answer, "reference": {}} + + time.sleep(0.1) # 增加延迟,确保缓冲区 flush 出去 diff --git a/web/src/hooks/write-hooks.ts b/web/src/hooks/write-hooks.ts index 3027e7e..3ae16bd 100644 --- a/web/src/hooks/write-hooks.ts +++ b/web/src/hooks/write-hooks.ts @@ -93,6 +93,7 @@ export const useSendMessageWithSse = (url: string = api.writeChat) => { console.info('data:', d); setAnswer({ ...d, + answer: d.answer, conversationId: body?.conversation_id, }); } diff --git a/web/src/pages/write/index.tsx b/web/src/pages/write/index.tsx index f618ea7..5c5a9e4 100644 --- a/web/src/pages/write/index.tsx +++ b/web/src/pages/write/index.tsx @@ -61,7 +61,7 @@ type MarkedSpaceToken = Tokens.Space; // 定义插入点标记,以便在onChange时识别并移除 // const INSERTION_MARKER = '【AI内容将插入此处】'; -const INSERTION_MARKER = ''; // 保持为空字符串,不显示实际标记 +const INSERTION_MARKER = ''; // 改成空字符串,发现使用插入点标记体验不佳; const Write = () => { const { t } = useTranslate('write'); @@ -72,7 +72,6 @@ const Write = () => { // cursorPosition 存储用户点击设定的插入点位置 const [cursorPosition, setCursorPosition] = useState(null); // showCursorIndicator 现在仅用于控制文档中是否显示 'INSERTION_MARKER', - // 并且一旦设置了光标位置,就希望它保持为 true,除非内容被清空或主动重置。 const [showCursorIndicator, setShowCursorIndicator] = useState(false); const textAreaRef = useRef(null); // Ant Design Input.TextArea 的 ref 类型 @@ -87,16 +86,15 @@ const Write = () => { const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState< string[] >([]); - const [similarityThreshold, setSimilarityThreshold] = useState(0.7); + const [similarityThreshold, setSimilarityThreshold] = useState(0.2); const [keywordSimilarityWeight, setKeywordSimilarityWeight] = - useState(0.5); - const [modelTemperature, setModelTemperature] = useState(0.7); + useState(0.7); + const [modelTemperature, setModelTemperature] = useState(1.0); const [knowledgeBases, setKnowledgeBases] = useState([]); const [isLoadingKbs, setIsLoadingKbs] = useState(false); const [isStreaming, setIsStreaming] = useState(false); // 标记AI是否正在流式输出 - // 新增状态和 useRef,用于流式输出管理 - // currentStreamedAiOutput 现在将直接接收 useSendMessageWithSse 返回的累积内容 + // currentStreamedAiOutput 直接接收 useSendMessageWithSse 返回的累积内容 const [currentStreamedAiOutput, setCurrentStreamedAiOutput] = useState(''); // 使用 useRef 存储 AI 插入点前后的内容,以及插入点位置,避免在流式更新中出现闭包陷阱 const contentBeforeAiInsertionRef = useRef(''); @@ -120,7 +118,7 @@ const Write = () => { { id: 'default_1_v4f', name: t('defaultTemplate'), - content: `# ${t('defaultTemplateTitle')}\n ## ${t('introduction')}\n ## ${t('mainContent')}\n \n ## ${t('conclusion')}\n `, + content: `# ${t('defaultTemplateTitle')}\n \n ## ${t('introduction')}\n \n ## ${t('mainContent')}\n \n ## ${t('conclusion')}\n `, isCustom: false, }, { @@ -191,8 +189,7 @@ const Write = () => { setContent(fallbackDefaults[0].content); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [t, getInitialDefaultTemplateDefinitions]); + }, [selectedTemplate, getInitialDefaultTemplateDefinitions, t]); useEffect(() => { loadOrInitializeTemplates(); @@ -213,7 +210,6 @@ const Write = () => { // --- 调整流式响应处理逻辑 --- // 阶段1: 累积 AI 输出片段,用于实时显示(包括 标签) - // 这个 useEffect 确保 currentStreamedAiOutput 始终是实时更新的、包含 标签的完整内容 useEffect(() => { if (isStreaming && answer && answer.answer) { setCurrentStreamedAiOutput(answer.answer); @@ -221,15 +217,10 @@ const Write = () => { }, [isStreaming, answer]); // 阶段2: 当流式输出完成时 (done 为 true) - // 这个 useEffect 负责在流式输出结束时执行清理和最终内容更新 useEffect(() => { if (done) { setIsStreaming(false); setIsAiLoading(false); - - // --- Process the final streamed AI output before committing --- - // 关键修改:这里**必须**使用 currentStreamedAiOutput,因为它是在流式过程中积累的、包含 标签的内容 - // answer.answer 可能在 done 阶段已经提前被钩子内部清理过,所以不能依赖它来获取带标签的原始内容。 let processedAiOutput = currentStreamedAiOutput; if (processedAiOutput) { // Regex to remove ... including content @@ -238,9 +229,8 @@ const Write = () => { '', ); } - // --- END NEW --- - // 将最终累积的AI内容(已处理移除标签)和初始文档内容拼接,更新到主内容状态 + // 将最终累积的AI内容和初始文档内容拼接,更新到主内容状态 setContent((prevContent) => { if (aiInsertionStartPosRef.current !== null) { // 使用 useRef 中存储的初始内容和最终处理过的 AI 输出 @@ -390,15 +380,16 @@ const Write = () => { const getContextContent = ( cursorPos: number, currentDocumentContent: string, - maxLength: number = 4000, + maxLength: number = 4000, // 截取插入点上下文总共4000个字符 ) => { // 注意: 这里的 currentDocumentContent 传入的是 AI 提问时编辑器里的总内容, // 而不是 contentBeforeAiInsertionRef + contentAfterAiInsertionRef,因为可能包含标记 const beforeCursor = currentDocumentContent.substring(0, cursorPos); const afterCursor = currentDocumentContent.substring(cursorPos); - // 使用更明显的插入点标记,这个标记是给AI看的,不是给用户看的 - const insertMarker = '[AI 内容插入点]'; + // 使用更明显的插入点标记 + // const insertMarker = '[AI 内容插入点]'; + const insertMarker = ''; const availableLength = maxLength - insertMarker.length; if (currentDocumentContent.length <= availableLength) { @@ -431,6 +422,7 @@ const Write = () => { }; }; + // 处理问题请求提交 const handleAiQuestionSubmit = async ( e: React.KeyboardEvent, ) => { @@ -453,9 +445,6 @@ const Write = () => { setIsStreaming(false); // 立即设置为false,中断流 setIsAiLoading(false); // 确保加载状态也停止 - // 中断时立即清除流中的 标签,并更新主内容 - // 这里使用 currentStreamedAiOutput 作为基准来构建中断时的内容, - // 因为它是屏幕上实际显示的,包含了 标签。 const contentToCleanOnInterrupt = contentBeforeAiInsertionRef.current + currentStreamedAiOutput + @@ -854,8 +843,6 @@ const Write = () => { setContent(finalContent); // 更新主内容状态 setCursorPosition(finalCursorPosition); // 更新光标位置状态 // 手动设置光标位置 - // 这里不能直接操作 DOM,因为是在 setState 之后,DOM 尚未更新 - // Ant Design Input.TextArea 会在 value 更新后自动处理光标位置 setShowCursorIndicator(true); // 用户输入时,表明已设置光标位置,持续显示标记 }} onClick={(e) => {