From a28ad2140ecea03e415fcecd64abc4ec3699f968 Mon Sep 17 00:00:00 2001 From: Administrator Date: Fri, 14 Aug 2026 11:29:58 +0800 Subject: [PATCH 1/3] fix: stabilize embedded WeKnora detail page --- .../llmwiki/WeKnoraKnowledgeDetailPage.tsx | 8 ++++ .../backend/app/gateway/routers/llmwiki.py | 3 ++ .../backend/tests/test_llmwiki_weknora.py | 46 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx index 2f4610e2..e733ae3a 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx @@ -51,7 +51,15 @@ function collectThemePayload(): DeerFlowThemePayload { function postThemeToFrame(frame: HTMLIFrameElement | null) { if (!frame?.contentWindow) return; const src = frame.getAttribute("src") || ""; + if (!src) return; const targetOrigin = new URL(src, window.location.href).origin; + try { + const currentUrl = frame.contentWindow.location.href; + if (!currentUrl || currentUrl === "about:blank") return; + if (new URL(currentUrl, window.location.href).origin !== targetOrigin) return; + } catch { + // Reading location throws once the iframe has reached the cross-origin backend. + } frame.contentWindow.postMessage(collectThemePayload(), targetOrigin); } diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index 2b86e743..024328fe 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -479,6 +479,9 @@ async def _authorize_weknora_api_proxy( if read_method and tail == ["me", "invitations", "pending-count"]: return + if read_method and tail == ["tenants", "kv", "retrieval-config"]: + return + if tail[:1] == ["knowledge-bases"]: if len(tail) == 1: if method == "GET": diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index ff358381..313b36a4 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -1425,3 +1425,49 @@ async def test_weknora_iframe_proxy_blocks_other_knowledge_bases() -> None: ) assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_weknora_iframe_proxy_only_reads_tenant_retrieval_config() -> None: + from app.gateway.routers import llmwiki as llmwiki_router + + class FakeRequest: + headers = Headers({}) + query_params = QueryParams("") + + def __init__(self, method: str) -> None: + self.method = method + + ctx = llmwiki_router.WeKnoraEmbedContext( + mapping_id="local-kb", + weknora_id="remote-kb", + user_id="admin-a", + is_admin=True, + can_write=True, + exp=4_102_444_800, + ) + + await llmwiki_router._authorize_weknora_api_proxy( + FakeRequest("GET"), + ctx, + object(), + "/api/v1/tenants/kv/retrieval-config", + b"", + "token", + ) + + for method, path in ( + ("GET", "/api/v1/tenants/kv/other-setting"), + ("PUT", "/api/v1/tenants/kv/retrieval-config"), + ): + with pytest.raises(HTTPException) as exc_info: + await llmwiki_router._authorize_weknora_api_proxy( + FakeRequest(method), + ctx, + object(), + path, + b"{}", + "token", + ) + + assert exc_info.value.status_code == 403 From 94cf37a2ae9253ddd180696c72f262ccae0484b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 17 Aug 2026 10:12:43 +0800 Subject: [PATCH 2/3] fix: normalize system messages before model calls --- .../middlewares/rag_citation_middleware.py | 7 +- .../writing_setup_round_cap_middleware.py | 11 +-- .../deerflow/models/message_ordering.py | 82 ++++++++++++++++++ .../deerflow/models/mindie_provider.py | 12 +-- .../deerflow/models/patched_deepseek.py | 23 ++--- .../deerflow/models/patched_minimax.py | 19 +++-- .../harness/deerflow/models/patched_openai.py | 27 +++--- .../harness/deerflow/models/vllm_provider.py | 23 ++--- .../harness/tests/test_message_ordering.py | 84 +++++++++++++++++++ 9 files changed, 236 insertions(+), 52 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/models/message_ordering.py create mode 100644 offline-backend-20260512/backend/packages/harness/tests/test_message_ordering.py diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/rag_citation_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/rag_citation_middleware.py index 07883766..53e47a4e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/rag_citation_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/rag_citation_middleware.py @@ -12,7 +12,7 @@ from typing import Any, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse -from langchain_core.messages import SystemMessage +from langchain_core.messages import HumanMessage from deerflow.runtime.references import ( build_latest_reference_prompt, @@ -105,7 +105,10 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]): ) patched_messages = [ *messages, - SystemMessage(content=prompt), + HumanMessage( + content=prompt, + additional_kwargs={"hide_from_ui": True, "lc_source": "rag_citation"}, + ), ] return request.override(messages=patched_messages) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/writing_setup_round_cap_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/writing_setup_round_cap_middleware.py index 5fadb642..cc27fd31 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/writing_setup_round_cap_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/writing_setup_round_cap_middleware.py @@ -14,13 +14,13 @@ from typing import override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse -from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.messages import HumanMessage class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]): """达到澄清轮数上限时,强制模型调用 ``setup_writing`` 提交。 - 软约束(注入 SystemMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型 + 软约束(注入隐藏 HumanMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型 无视提示,``tool_choice`` 也会逼它产出一次 ``setup_writing`` 调用。 """ @@ -33,8 +33,8 @@ class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]): human_count = sum(1 for m in messages if isinstance(m, HumanMessage)) return human_count >= self._max_rounds - def _reminder(self) -> SystemMessage: - return SystemMessage( + def _reminder(self) -> HumanMessage: + return HumanMessage( content=( f"【系统强制】意图澄清已达 {self._max_rounds} 轮上限。" "现在必须立即调用 setup_writing 提交写作配置,禁止再向用户提任何问题。" @@ -42,7 +42,8 @@ class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]): "word_count_target 默认 800、target_audience 默认「普通读者」、" "writing_mode 默认 loose、material_source 默认 knowledge_base、" "article_type 不确定就留空。" - ) + ), + additional_kwargs={"hide_from_ui": True, "lc_source": "writing_setup_round_cap"}, ) def _patch(self, request: ModelRequest) -> ModelRequest: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/message_ordering.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/message_ordering.py new file mode 100644 index 00000000..d7811b49 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/message_ordering.py @@ -0,0 +1,82 @@ +"""Utilities for provider-specific chat message ordering constraints.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.messages import BaseMessage, SystemMessage + + +_SYSTEM_SEPARATOR = "\n\n---\n\n" + + +def normalize_chat_payload_messages(payload: dict[str, Any]) -> None: + """Ensure payload ``system`` messages are represented once at the beginning. + + Several OpenAI-compatible gateways reject requests when a transient + ``system`` message appears after user/assistant/tool history. LangChain and + DeerFlow middleware can legitimately create those transient messages, so the + provider boundary folds every system payload into a single leading message + while preserving all non-system messages in their original order. + """ + messages = payload.get("messages") + if not isinstance(messages, list): + return + + normalized = _normalize_payload_message_list(messages) + if normalized is not messages: + payload["messages"] = normalized + + +def normalize_langchain_messages(messages: list[BaseMessage]) -> list[BaseMessage]: + """Return LangChain messages with all system context merged at the front.""" + system_messages = [message for message in messages if isinstance(message, SystemMessage)] + if not system_messages: + return messages + + first_non_system_index = next((idx for idx, message in enumerate(messages) if not isinstance(message, SystemMessage)), len(messages)) + if len(system_messages) == first_non_system_index: + return messages + + merged = system_messages[0].model_copy( + update={ + "content": _merge_contents([message.content for message in system_messages]), + } + ) + return [merged, *(message for message in messages if not isinstance(message, SystemMessage))] + + +def _normalize_payload_message_list(messages: list[Any]) -> list[Any]: + system_messages = [message for message in messages if isinstance(message, dict) and message.get("role") == "system"] + if not system_messages: + return messages + + first_non_system_index = next( + ( + idx + for idx, message in enumerate(messages) + if not (isinstance(message, dict) and message.get("role") == "system") + ), + len(messages), + ) + if len(system_messages) == first_non_system_index: + return messages + + merged_system = dict(system_messages[0]) + merged_system["content"] = _merge_contents([message.get("content") for message in system_messages]) + return [merged_system, *(message for message in messages if not (isinstance(message, dict) and message.get("role") == "system"))] + + +def _merge_contents(contents: list[Any]) -> Any: + if all(isinstance(content, str) for content in contents): + return _SYSTEM_SEPARATOR.join(content for content in contents if content) + + blocks: list[Any] = [] + for idx, content in enumerate(contents): + if idx > 0: + blocks.append({"type": "text", "text": _SYSTEM_SEPARATOR}) + if isinstance(content, list): + blocks.extend(content) + elif content is not None: + blocks.append({"type": "text", "text": str(content)}) + return blocks diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/mindie_provider.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/mindie_provider.py index bb32d1df..f745af3c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/mindie_provider.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/mindie_provider.py @@ -6,10 +6,12 @@ import uuid from collections.abc import Iterator import httpx -from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage -from langchain_core.outputs import ChatGenerationChunk, ChatResult -from langchain_openai import ChatOpenAI - +from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage +from langchain_core.outputs import ChatGenerationChunk, ChatResult +from langchain_openai import ChatOpenAI + +from deerflow.models.message_ordering import normalize_langchain_messages + def _fix_messages(messages: list) -> list: """Sanitize incoming messages for MindIE compatibility. @@ -55,7 +57,7 @@ def _fix_messages(messages: list) -> list: fixed.append(msg.model_copy(update={"content": text})) - return fixed + return normalize_langchain_messages(fixed) def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_deepseek.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_deepseek.py index 2baa3b05..deaac811 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_deepseek.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_deepseek.py @@ -9,10 +9,12 @@ on all assistant messages when thinking mode is enabled. from typing import Any -from langchain_core.language_models import LanguageModelInput -from langchain_core.messages import AIMessage -from langchain_deepseek import ChatDeepSeek - +from langchain_core.language_models import LanguageModelInput +from langchain_core.messages import AIMessage +from langchain_deepseek import ChatDeepSeek + +from deerflow.models.message_ordering import normalize_chat_payload_messages + class PatchedChatDeepSeek(ChatDeepSeek): """ChatDeepSeek with proper reasoning_content preservation. @@ -65,9 +67,10 @@ class PatchedChatDeepSeek(ChatDeepSeek): ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"] - for (idx, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): - reasoning_content = ai_msg.additional_kwargs.get("reasoning_content") - if reasoning_content is not None: - payload_messages[idx]["reasoning_content"] = reasoning_content - - return payload + for (idx, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): + reasoning_content = ai_msg.additional_kwargs.get("reasoning_content") + if reasoning_content is not None: + payload_messages[idx]["reasoning_content"] = reasoning_content + + normalize_chat_payload_messages(payload) + return payload diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_minimax.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_minimax.py index 946e4fe2..7d57f2d5 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_minimax.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_minimax.py @@ -20,11 +20,13 @@ from langchain_core.language_models import LanguageModelInput from langchain_core.messages import AIMessage, AIMessageChunk from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_openai import ChatOpenAI -from langchain_openai.chat_models.base import ( - _convert_delta_to_message_chunk, - _create_usage_metadata, -) - +from langchain_openai.chat_models.base import ( + _convert_delta_to_message_chunk, + _create_usage_metadata, +) + +from deerflow.models.message_ordering import normalize_chat_payload_messages + _THINK_TAG_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL) @@ -112,9 +114,10 @@ class PatchedChatMiniMax(ChatOpenAI): **extra_body, "reasoning_split": True, } - else: - payload["extra_body"] = {"reasoning_split": True} - return payload + else: + payload["extra_body"] = {"reasoning_split": True} + normalize_chat_payload_messages(payload) + return payload def _convert_chunk_to_generation_chunk( self, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py index f8bf1918..6dec1f5b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py @@ -29,9 +29,11 @@ from typing import Any from langchain_core.language_models import LanguageModelInput from langchain_core.messages import AIMessage, AIMessageChunk -from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult -from langchain_openai import ChatOpenAI - +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_openai import ChatOpenAI + +from deerflow.models.message_ordering import normalize_chat_payload_messages + class PatchedChatOpenAI(ChatOpenAI): """ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway. @@ -87,15 +89,16 @@ class PatchedChatOpenAI(ChatOpenAI): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): _restore_tool_call_signatures(payload_msg, orig_msg) _restore_reasoning_content(payload_msg, orig_msg) - else: - # Fallback: match assistant-role entries positionally against AIMessages. - ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] - assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"] - for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): - _restore_tool_call_signatures(payload_msg, ai_msg) - _restore_reasoning_content(payload_msg, ai_msg) - - return payload + else: + # Fallback: match assistant-role entries positionally against AIMessages. + ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] + assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"] + for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): + _restore_tool_call_signatures(payload_msg, ai_msg) + _restore_reasoning_content(payload_msg, ai_msg) + + normalize_chat_payload_messages(payload) + return payload def _convert_chunk_to_generation_chunk( self, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py index 78f83a73..d72bfb8c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py @@ -32,9 +32,11 @@ from langchain_core.messages import ( ) from langchain_core.messages.tool import tool_call_chunk from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult -from langchain_openai import ChatOpenAI -from langchain_openai.chat_models.base import _create_usage_metadata - +from langchain_openai import ChatOpenAI +from langchain_openai.chat_models.base import _create_usage_metadata + +from deerflow.models.message_ordering import normalize_chat_payload_messages + def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None: """Map DeerFlow's legacy ``thinking`` toggle to vLLM/Qwen's ``enable_thinking``. @@ -182,13 +184,14 @@ class VllmChatModel(ChatOpenAI): for payload_msg, orig_msg in zip(payload_messages, original_messages): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): _restore_reasoning_field(payload_msg, orig_msg) - else: - ai_messages = [message for message in original_messages if isinstance(message, AIMessage)] - assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"] - for payload_msg, ai_msg in zip(assistant_payloads, ai_messages): - _restore_reasoning_field(payload_msg, ai_msg) - - return payload + else: + ai_messages = [message for message in original_messages if isinstance(message, AIMessage)] + assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"] + for payload_msg, ai_msg in zip(assistant_payloads, ai_messages): + _restore_reasoning_field(payload_msg, ai_msg) + + normalize_chat_payload_messages(payload) + return payload def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult: """Preserve vLLM reasoning on non-streaming responses.""" diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_message_ordering.py b/offline-backend-20260512/backend/packages/harness/tests/test_message_ordering.py new file mode 100644 index 00000000..d289f9b4 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/tests/test_message_ordering.py @@ -0,0 +1,84 @@ +import pytest +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + +from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware +from deerflow.models.message_ordering import normalize_chat_payload_messages, normalize_langchain_messages +from deerflow.models.patched_openai import PatchedChatOpenAI + + +def _multi_turn_payload_with_late_systems(turns: int): + messages = [{"role": "system", "content": "base system"}] + for idx in range(turns): + messages.append({"role": "user", "content": f"user {idx}"}) + messages.append({"role": "assistant", "content": f"assistant {idx}"}) + if idx >= 9 and (idx + 1) % 10 == 0: + messages.append({"role": "system", "content": f"late summary after {idx + 1} turns"}) + messages.append({"role": "system", "content": "tail forced instruction"}) + return {"messages": messages} + + +@pytest.mark.parametrize("turns", [12, 25, 50]) +def test_normalize_chat_payload_messages_merges_late_systems_after_many_turns(turns): + payload = _multi_turn_payload_with_late_systems(turns) + + normalize_chat_payload_messages(payload) + + roles = [message["role"] for message in payload["messages"]] + assert roles[0] == "system" + assert roles.count("system") == 1 + assert roles[1:5] == ["user", "assistant", "user", "assistant"] + assert "base system" in payload["messages"][0]["content"] + assert "late summary after 10 turns" in payload["messages"][0]["content"] + assert "tail forced instruction" in payload["messages"][0]["content"] + + +def test_normalize_langchain_messages_moves_late_systems_before_history(): + messages = [SystemMessage(content="base system")] + for idx in range(12): + messages.append(HumanMessage(content=f"user {idx}")) + messages.append(AIMessage(content=f"assistant {idx}")) + if idx == 10: + messages.append(SystemMessage(content="late compaction summary")) + + normalized = normalize_langchain_messages(messages) + + assert isinstance(normalized[0], SystemMessage) + assert sum(isinstance(message, SystemMessage) for message in normalized) == 1 + assert normalized[0].content == "base system\n\n---\n\nlate compaction summary" + assert isinstance(normalized[1], HumanMessage) + assert normalized[1].content == "user 0" + + +@pytest.mark.parametrize("turns", [12, 25, 50]) +def test_patched_openai_payload_has_only_leading_system_after_many_turns(turns): + model = PatchedChatOpenAI( + model="test-model", + api_key="test-key", + base_url="http://127.0.0.1:9/v1", + ) + messages = [SystemMessage(content="base system")] + for idx in range(turns): + messages.append(HumanMessage(content=f"user {idx}")) + messages.append(AIMessage(content=f"assistant {idx}")) + if idx >= 9 and (idx + 1) % 10 == 0: + messages.append(SystemMessage(content=f"late system after {idx + 1} turns")) + messages.append(SystemMessage(content="tail system")) + + payload = model._get_request_payload(messages) + + roles = [message["role"] for message in payload["messages"]] + assert roles[0] == "system" + assert roles.count("system") == 1 + assert "base system" in payload["messages"][0]["content"] + assert "late system after 10 turns" in payload["messages"][0]["content"] + assert "tail system" in payload["messages"][0]["content"] + + +def test_writing_round_cap_reminder_is_not_late_system_message(): + middleware = WritingSetupRoundCapMiddleware(max_rounds=3) + + reminder = middleware._reminder() + + assert isinstance(reminder, HumanMessage) + assert not isinstance(reminder, SystemMessage) + assert reminder.additional_kwargs["hide_from_ui"] is True From 3ab2c9b28278615feb0932604ead06260808e5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 17 Aug 2026 15:53:19 +0800 Subject: [PATCH 3/3] feat: improve WeKnora source preview and wiki management --- .vscode/launch.json | 2 +- .vscode/tasks.json | 23 + .../workspace/skill-display-sources.tsx | 263 ++---- frontend-web/src/core/llmwiki/source-links.ts | 125 +++ frontend-web/src/main.tsx | 27 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 9 + .../src/strategy-components/api/llmwiki.ts | 124 +++ .../components/chat-qa/GeneralQA.mock.ts | 28 +- .../components/chat-qa/GeneralQA.tsx | 45 +- .../components/chat-qa/GeneralQA.types.ts | 17 +- .../components/chat-qa/QACommon.tsx | 41 +- .../llmwiki/WeKnoraKnowledgeDetailPage.tsx | 251 +++++- .../llmwiki/WeKnoraSourceDrawer.tsx | 710 ++++++++++++++++ .../llmwiki/WeKnoraWikiManagementPage.tsx | 765 ++++++++++++++++++ .../backend/app/gateway/routers/llmwiki.py | 347 +++++++- .../deerflow/integrations/weknora/client.py | 38 +- .../tools/builtins/llmwiki_search_tool.py | 3 + .../backend/tests/test_llmwiki_weknora.py | 33 + scripts/vscode-start.ps1 | 54 ++ 19 files changed, 2602 insertions(+), 303 deletions(-) create mode 100644 frontend-web/src/core/llmwiki/source-links.ts create mode 100644 frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer.tsx create mode 100644 frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage.tsx create mode 100644 scripts/vscode-start.ps1 diff --git a/.vscode/launch.json b/.vscode/launch.json index cdd14238..fb342a87 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -47,7 +47,7 @@ "name": "DeerFlow: Open Frontend in Chrome", "type": "node-terminal", "request": "launch", - "preLaunchTask": "DeerFlow: Start Full Stack and Wait", + "preLaunchTask": "DeerFlow: VS Code Start", "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"", "cwd": "${workspaceFolder}" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b8a70d8f..0886ef70 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,29 @@ { "version": "2.0.0", "tasks": [ + { + "label": "DeerFlow: VS Code Start", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/scripts/vscode-start.ps1", + "-OpenBrowser" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": [] + }, { "label": "DeerFlow: Start Full Stack and Wait", "type": "process", diff --git a/frontend-web/src/components/workspace/skill-display-sources.tsx b/frontend-web/src/components/workspace/skill-display-sources.tsx index 8e9950e2..b3db288a 100644 --- a/frontend-web/src/components/workspace/skill-display-sources.tsx +++ b/frontend-web/src/components/workspace/skill-display-sources.tsx @@ -1,4 +1,4 @@ -import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react"; +import { ExternalLinkIcon, FileTextIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -9,12 +9,12 @@ import { DialogTitle, } from "@/components/ui/dialog"; import type { RuntimeDisplaySource } from "@/core/skill-display/runtime"; -import { apiFetch } from "@/core/api/fetch-client"; -import { getBackendBaseURL } from "@/core/config"; +import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links"; import { useLocalSettings } from "@/core/settings"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { cn } from "@/lib/utils"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer"; export function SkillDisplaySources({ highlightedReferenceId, @@ -52,12 +52,14 @@ export function SourceCard({ source: RuntimeDisplaySource; referenceTarget?: boolean; }) { - const [open, setOpen] = useState(false); - const [context, setContext] = useState(null); - const [contextLoading, setContextLoading] = useState(false); - const [contextError, setContextError] = useState(null); + const [relatedOpen, setRelatedOpen] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); const [settings] = useLocalSettings(); const { data: citationDisplay } = useCitationDisplaySettings(); + const previewTarget = resolveWeKnoraSourceTarget({ + ...(source.raw || {}), + url: source.url, + }); const cleanupWords = citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; const content = cleanReferenceText(source.content || source.snippet, cleanupWords); @@ -78,57 +80,27 @@ export function SourceCard({ ].filter(Boolean); const isCitation = source.mode === "citation"; const showReferenceNumber = isCitation; - const previewTarget = parseWeKnoraPreviewUrl(source.url); - - useEffect(() => { - if (!open || !previewTarget) { - return; - } - let cancelled = false; - setContext(null); - setContextError(null); - setContextLoading(true); - const params = new URLSearchParams({ - knowledge_base_id: previewTarget.knowledgeBaseId, - chunk_id: previewTarget.chunkId, - }); - void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`) - .then(async (response) => { - const payload = (await response.json().catch(() => null)) as unknown; - if (!response.ok || !isWeKnoraSourceContextResponse(payload)) { - throw new Error(readPreviewError(payload, response.status)); - } - if (!cancelled) { - setContext(payload.data); - } - }) - .catch((error: unknown) => { - if (!cancelled) { - setContextError(error instanceof Error ? error.message : "无法加载原文上下文"); - } - }) - .finally(() => { - if (!cancelled) { - setContextLoading(false); - } - }); - return () => { - cancelled = true; - }; - }, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]); return ( <> - - - + {previewTarget && ( +
+ +
+ )} + + {previewTarget && ( + + )} + +
@@ -205,13 +202,7 @@ export function SourceCard({ )}
- {previewTarget ? ( - - ) : paragraphs.length > 0 ? ( + {paragraphs.length > 0 ? (
{paragraphs.map((paragraph, index) => (

@@ -231,156 +222,6 @@ export function SourceCard({ ); } -type WeKnoraPreviewTarget = { - knowledgeBaseId: string; - chunkId: string; -}; - -type WeKnoraSourceContext = { - knowledge_base_name?: string; - document_title?: string; - document_file_name?: string; - display_mode?: "wiki" | "chunks"; - focus_chunk_id: string; - wiki_page?: { - id?: string; - slug?: string; - title?: string; - page_type?: string; - status?: string; - summary?: string; - content?: string; - source_refs?: string[]; - } | null; - chunks: Array<{ - id: string; - chunk_index?: number; - content: string; - is_focus: boolean; - }>; -}; - -function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null { - if (!url?.startsWith("/weknora-source-preview?")) { - return null; - } - const queryIndex = url.indexOf("?"); - const params = new URLSearchParams(url.slice(queryIndex + 1)); - const knowledgeBaseId = params.get("knowledge_base_id")?.trim(); - const chunkId = params.get("chunk_id")?.trim(); - return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null; -} - -function isWeKnoraSourceContextResponse( - value: unknown, -): value is { success: true; data: WeKnoraSourceContext } { - if (!value || typeof value !== "object") { - return false; - } - const data = (value as { data?: unknown }).data; - return ( - (value as { success?: unknown }).success === true && - !!data && - typeof data === "object" && - Array.isArray((data as WeKnoraSourceContext).chunks) - ); -} - -function readPreviewError(payload: unknown, status: number): string { - if (payload && typeof payload === "object") { - const detail = (payload as { detail?: unknown }).detail; - if (typeof detail === "string" && detail) { - return detail; - } - } - return status === 404 ? "原文已不可用" : "无法加载原文上下文"; -} - -function WeKnoraSourceContextPanel({ - context, - loading, - error, -}: { - context: WeKnoraSourceContext | null; - loading: boolean; - error: string | null; -}) { - if (loading) { - return ( -

- - 正在加载原文上下文… -
- ); - } - if (error) { - return ( -
- {error} -
- ); - } - if (!context) { - return null; - } - const wikiPage = context.wiki_page; - const wikiContent = cleanReferenceText( - [wikiPage?.summary, wikiPage?.content].filter(Boolean).join("\n\n"), - [], - ); - if (wikiPage && wikiContent) { - return ( -
-
- {context.knowledge_base_name || "WeKnora 知识库"} - · LLMWiki - {wikiPage.page_type && · {wikiPage.page_type}} -
-
-
- - {wikiPage.title || wikiPage.slug || "LLMWiki 页面"} - - - 清洗页 - -
-
{wikiContent}
-
-
- ); - } - return ( -
-
- {context.knowledge_base_name || "WeKnora 知识库"} - {context.document_title && · {context.document_title}} -
- {context.chunks.map((chunk) => ( -
-
- 分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"} - {chunk.is_focus && ( - - 检索命中 - - )} -
-

{chunk.content}

-
- ))} -
- ); -} - function cleanReferenceText( raw: string | undefined, cleanupWords: string[], diff --git a/frontend-web/src/core/llmwiki/source-links.ts b/frontend-web/src/core/llmwiki/source-links.ts new file mode 100644 index 00000000..d91c0845 --- /dev/null +++ b/frontend-web/src/core/llmwiki/source-links.ts @@ -0,0 +1,125 @@ +import { readRuntimeEnv } from "@/env"; + +export type WeKnoraSourceTarget = { + knowledgeBaseId: string; + chunkId?: string; + documentId?: string; + wikiSlug?: string; + tab?: "documents" | "wiki" | "graph"; +}; + +export type KnowledgeSourceLike = { + url?: string | null; + recUuid?: string | null; + rec_uuid?: string | null; + knowledge_base_id?: string | null; + knowledgeBaseId?: string | null; + mapping_id?: string | null; + mappingId?: string | null; + chunk_id?: string | null; + chunkId?: string | null; + knowledge_id?: string | null; + knowledgeId?: string | null; + document_id?: string | null; + documentId?: string | null; + wiki_slug?: string | null; + wikiSlug?: string | null; + slug?: string | null; +}; + +export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null { + if (!url?.startsWith("/weknora-source-preview?")) { + return null; + } + const queryIndex = url.indexOf("?"); + const params = new URLSearchParams(url.slice(queryIndex + 1)); + const knowledgeBaseId = params.get("knowledge_base_id")?.trim(); + const chunkId = params.get("chunk_id")?.trim(); + const documentId = + params.get("document_id")?.trim() || + params.get("knowledge_id")?.trim() || + ""; + const wikiSlug = + params.get("wiki_slug")?.trim() || + params.get("wikiSlug")?.trim() || + params.get("slug")?.trim() || + ""; + return knowledgeBaseId && chunkId + ? { + knowledgeBaseId, + chunkId, + documentId: documentId || undefined, + wikiSlug: wikiSlug || undefined, + tab: wikiSlug ? "wiki" : "documents", + } + : null; +} + +export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnoraSourceTarget | null { + const fromPreview = parseWeKnoraPreviewUrl(source.url); + const knowledgeBaseId = + source.knowledge_base_id || + source.knowledgeBaseId || + source.mapping_id || + source.mappingId || + fromPreview?.knowledgeBaseId || + ""; + if (!knowledgeBaseId) return null; + + const wikiSlug = source.wiki_slug || source.wikiSlug || source.slug || fromPreview?.wikiSlug || ""; + const documentId = + source.document_id || + source.documentId || + source.knowledge_id || + source.knowledgeId || + fromPreview?.documentId || + ""; + const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || ""; + return { + knowledgeBaseId, + chunkId: chunkId || undefined, + documentId: documentId || undefined, + wikiSlug: wikiSlug || undefined, + tab: wikiSlug ? "wiki" : "documents", + }; +} + +export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string { + const params = new URLSearchParams(); + if (target.tab) params.set("tab", target.tab); + if (target.chunkId) params.set("sourceChunkId", target.chunkId); + if (target.documentId) params.set("documentId", target.documentId); + if (target.wikiSlug) params.set("wikiSlug", target.wikiSlug); + const query = params.toString(); + return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`; +} + +export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string { + const recUuid = source.recUuid || source.rec_uuid || source.url || ""; + if (!recUuid) return ""; + if (/^https?:\/\//i.test(recUuid)) return recUuid; + const base = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL"); + if (base) { + return `${base.replace(/\/+$/, "")}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`; + } + return ""; +} + +export function openKnowledgeSource(source: KnowledgeSourceLike): boolean { + const weknoraTarget = resolveWeKnoraSourceTarget(source); + const href = weknoraTarget + ? buildWeKnoraSourceIframeUrl(weknoraTarget) + : buildLegacyKnowledgeSourceUrl(source); + if (!href) return false; + window.open(href, "_blank", "noopener,noreferrer"); + return true; +} + +export function normalizeMalformedWeKnoraHashRoute(): boolean { + if (typeof window === "undefined") return false; + const { pathname, search, hash } = window.location; + if (!pathname.startsWith("/page/workspace/knowledge/")) return false; + if (hash && !hash.startsWith("#/")) return false; + window.location.replace(`/#${pathname}${search}`); + return true; +} diff --git a/frontend-web/src/main.tsx b/frontend-web/src/main.tsx index 263da283..35280c2e 100644 --- a/frontend-web/src/main.tsx +++ b/frontend-web/src/main.tsx @@ -10,15 +10,18 @@ import "katex/dist/katex.min.css"; import React from "react"; import ReactDOM from "react-dom/client"; -import { App } from "./App"; -import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session"; - -// 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入, -// 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。 -syncEmbedSessionFromCurrentUrl(); - -ReactDOM.createRoot(document.getElementById("root")!).render( - - - , -); +import { App } from "./App"; +import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session"; +import { normalizeMalformedWeKnoraHashRoute } from "@/core/llmwiki/source-links"; + +if (!normalizeMalformedWeKnoraHashRoute()) { + // 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入, + // 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。 + syncEmbedSessionFromCurrentUrl(); + + ReactDOM.createRoot(document.getElementById("root")!).render( + + + , + ); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 926c8992..e145384d 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -36,6 +36,7 @@ import AdminSkillsPage from './AdminSkillsPage'; import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage'; import ScheduledTasksPage from './ScheduledTasksPage'; import LlmWikiKnowledgePage from './LlmWikiKnowledgePage'; +import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage'; import LlmMetricsPage from './LlmMetricsPage'; import CheckpointMigrationPage from './CheckpointMigrationPage'; import MemoryPage from './MemoryPage'; @@ -616,6 +617,14 @@ export default function WorkspaceRoutes({ } /> + + + + } + /> ; +} + export interface LlmWikiSearchResult { chunk_id: string; content: string; @@ -159,6 +176,33 @@ export interface LlmWikiWeKnoraEmbedSession { expires_in: number; } +export interface LlmWikiWikiPageCreate { + slug: string; + title: string; + content: string; + summary?: string; + page_type?: string; + status?: string; + aliases?: string[]; + parent_slug?: string; + category_path?: string[]; + folder_id?: string; + wiki_path?: string; +} + +export interface LlmWikiWikiPageUpdate { + title?: string; + content?: string; + summary?: string; + page_type?: string; + status?: string; + aliases?: string[]; + parent_slug?: string; + category_path?: string[]; + folder_id?: string; + wiki_path?: string; +} + async function readJson(response: Response, context: string): Promise { if (!response.ok) { const payload = await response.json().catch(() => null as unknown); @@ -226,6 +270,21 @@ export async function createLlmWikiWeKnoraEmbedSession( ); } +export async function getLlmWikiSourceContext( + knowledgeBaseId: string, + chunkId: string, +): Promise { + const params = new URLSearchParams({ + knowledge_base_id: knowledgeBaseId, + chunk_id: chunkId, + }); + const payload = await readJson<{ success: boolean; data: LlmWikiSourceContext }>( + await apiFetch(`${API()}/sources/context?${params}`), + "加载参考文献原文", + ); + return payload.data; +} + export async function createLlmWikiKnowledgeBase(input: { name: string; description: string; @@ -353,6 +412,56 @@ export async function getLlmWikiWikiPage( ); } +export async function createLlmWikiWikiPage( + knowledgeBaseId: string, + input: LlmWikiWikiPageCreate, +): Promise { + return readJson( + await apiFetch( + `${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ), + "创建 Wiki 页面", + ); +} + +export async function updateLlmWikiWikiPage( + knowledgeBaseId: string, + slug: string, + input: LlmWikiWikiPageUpdate, +): Promise { + const safeSlug = slug.split("/").map(encodeURIComponent).join("/"); + return readJson( + await apiFetch( + `${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ), + "鏇存柊 Wiki 椤甸潰", + ); +} + +export async function deleteLlmWikiWikiPage( + knowledgeBaseId: string, + slug: string, +): Promise { + const safeSlug = slug.split("/").map(encodeURIComponent).join("/"); + await readJson( + await apiFetch( + `${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`, + { method: "DELETE" }, + ), + "鍒犻櫎 Wiki 椤甸潰", + ); +} + export async function uploadLlmWikiDocument( knowledgeBaseId: string, file: File, @@ -698,6 +807,21 @@ export function useLlmWikiActions() { reprocessLlmWikiDocument(id, documentId), onSuccess: invalidateKnowledge, }), + createWikiPage: useMutation({ + mutationFn: ({ id, input }: { id: string; input: LlmWikiWikiPageCreate }) => + createLlmWikiWikiPage(id, input), + onSuccess: invalidateKnowledge, + }), + updateWikiPage: useMutation({ + mutationFn: ({ id, slug, input }: { id: string; slug: string; input: LlmWikiWikiPageUpdate }) => + updateLlmWikiWikiPage(id, slug, input), + onSuccess: invalidateKnowledge, + }), + removeWikiPage: useMutation({ + mutationFn: ({ id, slug }: { id: string; slug: string }) => + deleteLlmWikiWikiPage(id, slug), + onSuccess: invalidateKnowledge, + }), publication: useMutation({ mutationFn: ({ id, publish }: { id: string; publish: boolean }) => setLlmWikiPublication(id, publish), diff --git a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.mock.ts b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.mock.ts index d6f235bf..62bc6cb4 100644 --- a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.mock.ts +++ b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.mock.ts @@ -310,11 +310,29 @@ export const createMockSingleRagMessages = (): Message[] => ([ /** * 将多轮检索接口返回的 references 项映射为通用引用来源。 */ -export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({ +export const mapReferenceToSource = (item: { + title?: string; + content?: string; + snippet?: string; + url?: string; + knowledge_base_id?: string; + knowledge_id?: string; + document_id?: string; + chunk_id?: string; + wiki_slug?: string; + slug?: string; +}): SourceItem => ({ title: item.title || '未命名来源', type: '参考资料', snippet: item.snippet || item.content || '', content: item.content || item.snippet || '', + url: item.url, + knowledge_base_id: item.knowledge_base_id, + knowledge_id: item.knowledge_id, + document_id: item.document_id, + chunk_id: item.chunk_id, + wiki_slug: item.wiki_slug, + slug: item.slug, }); /** @@ -343,6 +361,12 @@ export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] source: item.source || item.title || '', time: '', url: item.url, + knowledge_base_id: (item as any).knowledge_base_id, + knowledge_id: (item as any).knowledge_id, + document_id: (item as any).document_id, + chunk_id: (item as any).chunk_id, + wiki_slug: (item as any).wiki_slug, + slug: (item as any).slug, content: item.content || item.snippet || '', }; }); @@ -447,4 +471,4 @@ export const platformTypeChange = (platform: string) => { platformType = 'facebook'; } return platformType; - } \ No newline at end of file + } diff --git a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx index 674a4ef6..95403f81 100644 --- a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx +++ b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx @@ -30,7 +30,9 @@ import { import { requestCrudService } from '@/service/request-axios-service'; import { authStorageGet } from '@/core/auth/scoped-storage'; import { copyTextToClipboard } from '@/core/clipboard'; +import { openKnowledgeSource, resolveWeKnoraSourceTarget, type WeKnoraSourceTarget } from '@/core/llmwiki/source-links'; import { readRuntimeEnv } from '@/env'; +import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer'; /** * 通用问答组件 @@ -43,7 +45,6 @@ const zncmUrl = readRuntimeEnv('VITE_API_BASE_URL') || ''; const chatUrl = readRuntimeEnv('VITE_AIPI_CHAT_URL') || ''; const generateUrl = readRuntimeEnv('VITE_AIPI_GENERATE_CHAT_URL') || ''; const taskChatUrl = readRuntimeEnv('VITE_AIPI_TASK_CHAT_URL') || ''; -const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL'); const service = requestCrudService; const GeneralQA: React.FC<{ onNavigate?: (page: string) => void; @@ -110,6 +111,10 @@ const GeneralQA: React.FC<{ const [showScrollToBottom, setShowScrollToBottom] = useState(false); // 当前选中的引用来源,用于显示引用详情弹窗 const [selectedCitation, setSelectedCitation] = useState(null); + const [weknoraDrawer, setWeknoraDrawer] = useState<{ + target: WeKnoraSourceTarget; + title: string; + } | null>(null); const [editingMessageId, setEditingMessageId] = useState(null); const [editingThinking, setEditingThinking] = useState(''); const [editingAnswer, setEditingAnswer] = useState(''); @@ -384,6 +389,13 @@ const GeneralQA: React.FC<{ source: item.source1, time: item.m_publish, url: item.recUuid, + recUuid: item.recUuid, + knowledge_base_id: item.knowledge_base_id, + knowledge_id: item.knowledge_id, + document_id: item.document_id, + chunk_id: item.chunk_id, + wiki_slug: item.wiki_slug, + slug: item.slug, content: item.m_content || '', })); // 更新检索统计信息 @@ -2380,12 +2392,22 @@ const GeneralQA: React.FC<{
- {selectedCitation.url && ( + {(selectedCitation.url || resolveWeKnoraSourceTarget(selectedCitation)) && ( )} @@ -2406,6 +2428,15 @@ const GeneralQA: React.FC<{ )} + { + if (!nextOpen) setWeknoraDrawer(null); + }} + /> + ({ const isDark = theme === 'dark'; const isUser = message.role === 'user'; const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace); - const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL'); const [visible, setVisible] = useState(false); // 打开详情对话框 const [listData, setListData] = useState({ title: '', @@ -252,10 +253,17 @@ export function ChatMessage({ source: '', content: '', }); + const [weknoraDrawer, setWeknoraDrawer] = useState<{ + target: WeKnoraSourceTarget; + title: string; + } | null>(null); const showDialog = (item: SourceItem) => { console.log('item', item) - if (item.url) { - window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`); + const weknoraTarget = resolveWeKnoraSourceTarget(item); + if (weknoraTarget) { + setWeknoraDrawer({ target: weknoraTarget, title: item.title || '参考文献原文' }); + } else if (item.url) { + openKnowledgeSource(item); } else { setListData(item); setVisible(true); @@ -361,12 +369,19 @@ export function ChatMessage({
时间:{listData.time || '未知'}
- {listData.url && ( + {(listData.url || resolveWeKnoraSourceTarget(listData)) && ( )}
({
+ { + if (!nextOpen) setWeknoraDrawer(null); + }} + /> {!isUser && renderAssistantExtra?.(message)} diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx index e733ae3a..40fe6e83 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx @@ -1,14 +1,24 @@ -import { AlertCircle, Loader2, RefreshCw, Upload } from "lucide-react"; +import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2, Upload } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { Navigate, useNavigate, useParams } from "react-router-dom"; +import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { getBackendBaseURL } from "@/core/config"; import { createLlmWikiWeKnoraEmbedSession, + getLlmWikiSourceContext, useLlmWikiActions, useLlmWikiKnowledgeBase, + useLlmWikiWikiPage, useLlmWikiRuntime, } from "@/strategy-components/api/llmwiki"; @@ -86,10 +96,12 @@ export function WeKnoraKnowledgeDetailPage() { noteId: workspaceKnowledgeBaseId, } = useParams(); const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? ""; + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const frameRef = useRef(null); const fileInputRef = useRef(null); const [frameSrc, setFrameSrc] = useState(""); + const [frameVersion, setFrameVersion] = useState(0); const [frameError, setFrameError] = useState(null); const [uploading, setUploading] = useState(false); const runtime = useLlmWikiRuntime(); @@ -97,7 +109,16 @@ export function WeKnoraKnowledgeDetailPage() { const actions = useLlmWikiActions(); const detailsReady = !!details.data; - const preferredTab = details.data?.indexing_strategy?.wiki_enabled ? "wiki" : "documents"; + const requestedTab = searchParams.get("tab"); + const preferredTab = + requestedTab === "documents" || requestedTab === "wiki" || requestedTab === "graph" + ? requestedTab + : details.data?.indexing_strategy?.wiki_enabled + ? "wiki" + : "documents"; + const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || ""; + const documentId = searchParams.get("documentId") || ""; + const wikiSlug = searchParams.get("wikiSlug") || ""; useEffect(() => { let cancelled = false; @@ -109,14 +130,24 @@ export function WeKnoraKnowledgeDetailPage() { }; } - void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab) - .then((session) => { + void (async () => { + const resolvedWikiSlug = + wikiSlug || + (sourceChunkId + ? (await getLlmWikiSourceContext(knowledgeBaseId, sourceChunkId).catch( + () => null, + ))?.wiki_page?.slug + : ""); + const session = await createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab); if (cancelled) return; const framePath = session.frame_path || ""; const nextUrl = buildFrameUrl(framePath); nextUrl.searchParams.set("theme", collectThemePayload().theme); + if (sourceChunkId) nextUrl.searchParams.set("sourceChunkId", sourceChunkId); + if (documentId) nextUrl.searchParams.set("documentId", documentId); + if (resolvedWikiSlug) nextUrl.searchParams.set("wikiSlug", resolvedWikiSlug); setFrameSrc(nextUrl.toString()); - }) + })() .catch((error: unknown) => { if (cancelled) return; const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败"; @@ -126,7 +157,7 @@ export function WeKnoraKnowledgeDetailPage() { return () => { cancelled = true; }; - }, [detailsReady, knowledgeBaseId, preferredTab, runtime.data?.provider]); + }, [detailsReady, documentId, knowledgeBaseId, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]); useEffect(() => { if (!frameSrc) return; @@ -194,11 +225,14 @@ export function WeKnoraKnowledgeDetailPage() { } const canUpload = details.data.can_write && !details.data.is_conversation_deposit; + const canEditCurrentWikiArticle = + canUpload && !!details.data.indexing_strategy?.wiki_enabled && !!wikiSlug; + const canManageWiki = canUpload && !!details.data.indexing_strategy?.wiki_enabled; return (
- {canUpload && ( -
+ {(canUpload || canEditCurrentWikiArticle) && ( +
0) { await details.refetch(); - if (frameRef.current) frameRef.current.src = frameSrc; + setFrameVersion((value) => value + 1); } if (failed.length === 0) { toast.success(`已上传 ${succeeded} 个文件,正在处理`); @@ -237,23 +271,53 @@ export function WeKnoraKnowledgeDetailPage() { })(); }} /> - +
+
+ {canManageWiki && ( + + )} + {canUpload && ( + + )} +
)} {frameSrc && (