From 5b610fd8074867d1fea964747c6edb9e87a222c1 Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 16 Jun 2026 22:15:36 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E6=94=AF=E6=8C=81=E7=8A=B6=E6=80=81=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E7=BC=93=E5=AD=98,=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E9=9A=90=E8=97=8F=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summarization_middleware 摘要缓存写入 LangGraph thread state,跨进程/重启可复用 - 摘要消息改为 SystemMessage,提示模型静默使用、不泄露 - thread_state 新增 context_summary_cache 字段 - app_config 加载时记录 summarization 配置日志 - config.yaml 压缩触发/保留阈值调整为生产值(token 预算) - 前端 input-box / message-list 过滤 hide_from_ui 消息 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/workspace/input-box.tsx | 10 +- .../workspace/messages/message-list.tsx | 12 +- offline-backend-20260512/backend/config.yaml | 10 +- .../middlewares/summarization_middleware.py | 145 +++++++++++++----- .../harness/deerflow/agents/thread_state.py | 5 +- .../harness/deerflow/config/app_config.py | 31 ++-- offline-backend-20260512/config.yaml | 17 +- 7 files changed, 159 insertions(+), 71 deletions(-) diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index f4a30d63..39692e5d 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -61,8 +61,9 @@ import { } from "@/components/ui/dropdown-menu"; import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; -import { useI18n } from "@/core/i18n/hooks"; -import { useModels } from "@/core/models/hooks"; +import { useI18n } from "@/core/i18n/hooks"; +import { isHiddenFromUIMessage } from "@/core/messages/utils"; +import { useModels } from "@/core/models/hooks"; import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; @@ -467,14 +468,15 @@ export function InputBox({ return; } - const lastAi = [...thread.messages].reverse().find((m) => m.type === "ai"); + const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m)); + const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai"); const lastAiId = lastAi?.id ?? null; if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { return; } lastGeneratedForAiIdRef.current = lastAiId; - const recent = thread.messages + const recent = visibleMessages .filter((m) => m.type === "human" || m.type === "ai") .map((m) => { const role = m.type === "human" ? "user" : "assistant"; diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 68aa9429..f10dc1bc 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -20,9 +20,10 @@ import { groupMessages, hasContent, hasPresentFiles, - hasReasoning, - hasToolCalls, -} from "@/core/messages/utils"; + hasReasoning, + hasToolCalls, + isHiddenFromUIMessage, +} from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; @@ -151,7 +152,10 @@ export function MessageList({ }); }, []); - const allMessages = thread.messages; + const allMessages = useMemo( + () => thread.messages.filter((m) => !isHiddenFromUIMessage(m)), + [thread.messages], + ); const messages = useMemo( () => hiddenMessageIds.size === 0 diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 0ca799fe..c23bb4ce 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -207,13 +207,13 @@ summarization: # model_name: deepseek-chat # 留空则用主对话模型 trigger: - type: tokens - value: 8000 + value: 120000 - type: messages - value: 12 + value: 160 keep: - type: messages - value: 6 - trim_tokens_to_summarize: 4000 + type: tokens + value: 40000 + trim_tokens_to_summarize: 8000 tool_groups: - name: web diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index 11e7d78c..3292f89f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -27,7 +27,14 @@ from typing import Any, Protocol, override, runtime_checkable from langchain.agents import AgentState from langchain.agents.middleware import SummarizationMiddleware from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse -from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage +from langchain_core.messages import ( + AIMessage, + AnyMessage, + SystemMessage, + ToolMessage, + message_to_dict, + messages_from_dict, +) from langgraph.config import get_config from langgraph.runtime import Runtime @@ -138,7 +145,8 @@ class _SummaryCacheEntry: summarized_ids: frozenset[str] summary_text: str - reusable: bool + preserved_ids: frozenset[str] = frozenset() + preserved_messages: tuple[AnyMessage, ...] = () # Process-wide summary cache shared across every middleware instance. @@ -152,14 +160,51 @@ class _SummaryCacheEntry: # so only the first threshold crossing (or a genuine re-compaction) pays for an # LLM call. A lock guards the non-atomic LRU eviction against concurrent # threads (subagents / parallel conversations share this process). -_GLOBAL_SUMMARY_CACHE: "OrderedDict[str, _SummaryCacheEntry]" = OrderedDict() -_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock() - - -def _clear_global_summary_cache() -> None: - """Drop every cached summary. Intended for tests/diagnostics only.""" - with _GLOBAL_SUMMARY_CACHE_LOCK: - _GLOBAL_SUMMARY_CACHE.clear() +_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict() +_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock() +_STATE_CACHE_KEY = "context_summary_cache" + + +def _clear_global_summary_cache() -> None: + """Drop every cached summary. Intended for tests/diagnostics only.""" + with _GLOBAL_SUMMARY_CACHE_LOCK: + _GLOBAL_SUMMARY_CACHE.clear() + + +def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]: + """Serialize a summary cache entry so it can live in LangGraph state.""" + return { + "version": 1, + "summarized_ids": sorted(entry.summarized_ids), + "summary_text": entry.summary_text, + "preserved_ids": sorted(entry.preserved_ids), + "preserved_messages": [message_to_dict(message) for message in entry.preserved_messages], + } + + +def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None: + """Best-effort restore of a state-backed summary cache entry.""" + if not isinstance(raw, dict): + return None + try: + summary_text = raw.get("summary_text") + if not isinstance(summary_text, str) or not summary_text: + return None + summarized_ids = raw.get("summarized_ids") or [] + preserved_ids = raw.get("preserved_ids") or [] + preserved_messages_raw = raw.get("preserved_messages") or [] + if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list): + return None + preserved_messages = tuple(messages_from_dict(preserved_messages_raw)) + return _SummaryCacheEntry( + summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None), + summary_text=summary_text, + preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None), + preserved_messages=preserved_messages, + ) + except Exception: + logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True) + return None class DeerFlowSummarizationMiddleware(SummarizationMiddleware): @@ -186,7 +231,8 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): # Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text). # Points at the *process-wide* cache (not a fresh per-instance dict) so a # summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``. - self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE + self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE + self._pending_state_cache_update: dict[str, Any] | None = None @override def before_model(self, state: AgentState, runtime: Runtime) -> dict | None: @@ -202,7 +248,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): """Async counterpart of :meth:`before_model` — also a no-op.""" return None - # ---- wrap_model_call: transient, display-preserving compression ---------- + @override + def after_model(self, state: AgentState, runtime: Runtime) -> dict | None: + """Persist the latest reusable summary cache into thread state.""" + if self._pending_state_cache_update is None: + return None + update = self._pending_state_cache_update + self._pending_state_cache_update = None + return {_STATE_CACHE_KEY: update} + + @override + async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None: + """Async counterpart of :meth:`after_model`.""" + return self.after_model(state, runtime) + + # ---- wrap_model_call: transient, display-preserving compression ---------- @override def wrap_model_call( @@ -316,14 +376,20 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): return None thread_id = self._resolve_request_thread_id(request) - if thread_id is None: - logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call") - cached = self._get_cache_entry(thread_id) if thread_id else None - present_ids = {m.id for m in messages} - cache_usable = cached is not None and cached.reusable and cached.summarized_ids <= present_ids - if cache_usable: - preserved = [m for m in messages if m.id not in cached.summarized_ids] - effective = [self._summary_message(cached.summary_text), *preserved] + if thread_id is None: + logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call") + cached = self._get_cache_entry(thread_id) if thread_id else None + if cached is None: + cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY)) + if cached is not None and thread_id: + self._store_cache_entry(thread_id, cached) + present_ids = {m.id for m in messages} + cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids + if cache_usable: + preserved = list(cached.preserved_messages) + excluded_ids = cached.summarized_ids | cached.preserved_ids + preserved.extend(m for m in messages if m.id not in excluded_ids) + effective = [self._summary_message(cached.summary_text), *preserved] # Decide re-summarization on the *counted* size of the compressed # context only — NOT ``_should_summarize`` — because that folds in the # last AIMessage's reported ``usage_metadata`` (system prompt + tool @@ -349,7 +415,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): # an "it compacts every turn" report is diagnosable straight from the # backend log: cache=miss → thread_id/key problem; unusable → message-id # instability; stale → preserved genuinely grew back over the trigger. - cache_state = "stale" if cache_usable else ("miss" if cached is None else "unusable") + cache_state = "miss" if cached is None else "stale" logger.info( "summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d", thread_id, @@ -390,16 +456,18 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): if thread_id: summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None) preserved_ids = {m.id for m in preserved if m.id is not None} - # A non-empty overlap means skill rescue split an AIMessage across the - # boundary; reconstructing ``preserved`` by id filtering later could - # orphan a tool result, so mark the entry non-reusable (re-summarize). - reusable = summarized_ids.isdisjoint(preserved_ids) - self._store_cache_entry( - thread_id, - _SummaryCacheEntry(summarized_ids=summarized_ids, summary_text=summary, reusable=reusable), - ) - - return compressed + self._store_cache_entry( + thread_id, + entry := _SummaryCacheEntry( + summarized_ids=summarized_ids, + summary_text=summary, + preserved_ids=frozenset(preserved_ids), + preserved_messages=tuple(preserved), + ), + ) + self._pending_state_cache_update = _serialize_cache_entry(entry) + + return compressed def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None: """Return the cached entry for ``thread_id`` (thread-safe read).""" @@ -503,7 +571,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): return None return _resolve_thread_id(runtime) - def _summary_message(self, summary: str) -> HumanMessage: + def _summary_message(self, summary: str) -> SystemMessage: """Build the summary message handed to the model. It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because @@ -511,14 +579,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): state), the frontend never receives it — the user keeps seeing the original conversation. """ - return HumanMessage( - content=f"Here is a summary of the conversation to date:\n\n{summary}", + return SystemMessage( + content=( + "Internal conversation summary for continuity only. Use it silently " + "to answer the user's latest request. Never reveal, quote, mention, " + "or describe this summary or the fact that context was compacted.\n\n" + "\n" + f"{summary}\n" + "" + ), name="summary", additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"}, ) @override - def _build_new_messages(self, summary: str) -> list[HumanMessage]: + def _build_new_messages(self, summary: str) -> list[AnyMessage]: """Kept for backwards compatibility; delegates to :meth:`_summary_message`.""" return [self._summary_message(summary)] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py index cdd3f95d..c1b9948b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py @@ -83,5 +83,6 @@ class ThreadState(AgentState): highlighted_code: NotRequired[Optional[str]] canvas_mode: NotRequired[Optional[Literal["text", "code"]]] quick_action_id: NotRequired[Optional[str]] - web_search_enabled: NotRequired[bool] - web_search_results: NotRequired[Optional[list[dict]]] + web_search_enabled: NotRequired[bool] + web_search_results: NotRequired[Optional[list[dict]]] + context_summary_cache: NotRequired[dict | None] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index 205b88e4..cb277ee0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -387,17 +387,26 @@ def _get_config_mtime(config_path: Path) -> float | None: return None -def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig: - """Load config from disk and refresh cache metadata.""" - global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom - - resolved_path = AppConfig.resolve_config_path(config_path) - _app_config = AppConfig.from_file(str(resolved_path)) - _app_config_path = resolved_path - _app_config_mtime = _get_config_mtime(resolved_path) - _app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime() - _app_config_is_custom = False - return _app_config +def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig: + """Load config from disk and refresh cache metadata.""" + global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom + + resolved_path = AppConfig.resolve_config_path(config_path) + _app_config = AppConfig.from_file(str(resolved_path)) + _app_config_path = resolved_path + _app_config_mtime = _get_config_mtime(resolved_path) + _app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime() + _app_config_is_custom = False + summarization = _app_config.summarization + logger.info( + "Loaded AppConfig from %s; summarization enabled=%s trigger=%s keep=%s trim_tokens_to_summarize=%s", + resolved_path, + summarization.enabled, + summarization.trigger, + summarization.keep, + summarization.trim_tokens_to_summarize, + ) + return _app_config def get_app_config() -> AppConfig: diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index be0d5b21..0bc2744e 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -310,20 +310,17 @@ summarization: # model_name: deepseek-chat trigger: - type: tokens - value: 8000 + value: 120000 - type: messages - value: 12 + value: 160 # Keep a small *token* budget of recent context (same unit as the token # trigger, well below it). This is what the model receives after compaction: - # [summary, ~16000 tokens of recent turns]. Small keep = small model context + # [summary, ~40000 tokens of recent turns]. Small keep = small model context # (fast, no overflow) AND infrequent re-compaction — the preserved window - # grows from ~16000 up to the 60000 trigger before folding into a new summary, + # grows from ~40000 up to the 120000 trigger before folding into a new summary, # which spans many turns. The full conversation still stays in state for the # page; only what is sent to the model is compressed. keep: - type: messages # 测试值:压缩后只保留最近 6 条原始消息 - value: 6 - # 生产值改用 token 预算: - # type: tokens - # value: 16000 - trim_tokens_to_summarize: 4000 + type: tokens + value: 40000 + trim_tokens_to_summarize: 8000 From a2d767e2db5a0f7bbea006869ff0bd39ab27579c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 17 Jun 2026 15:59:02 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20rwfx=20=E4=BB=BB=E5=8A=A1=E6=B7=B1?= =?UTF-8?q?=E9=93=BE=E6=94=AF=E6=8C=81=E7=9B=AE=E7=9A=84=E6=A0=91=E9=97=B8?= =?UTF-8?q?=E9=97=A8=20+=20=E4=BB=BB=E5=8A=A1=E2=86=94=E5=AF=B9=E8=AF=9D(1?= =?UTF-8?q?:N)=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 - config.yaml 新增 task_deeplink 段:完整 URL(无 base)+ test/delete_test 两个独立测试开关 + mock 假数据(目的树 / delete 结果,可随时编辑) - 新增 TaskDeeplinkConfig 模型挂到 AppConfig;新增公开只读接口 GET /api/public/config/task-deeplink 暴露给前端(/api/public 已在鉴权白名单) 前端 - 登录深链 goPath=rwfx 进入「任务工作区」:左侧按 metadata.taskId 过滤的对话列表 (新建对话 / 编辑 / 下一步 / 清除任务记录)+ 右侧问答 - 新建对话(含首次进入)顶部展示目的树闸门:有数据弹覆盖确认→调 delete,success 才开始问答; 恢复历史对话不跑闸门 - 新建 thread 打 metadata.taskId 标签实现「一个任务挂多个对话」(1:N) - 地址/开关/假数据全部走后端配置,拿不到时用内置默认值兜底;test/delete_test=true 走假数据 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/core/auth/task-deeplink-config.ts | 104 +++++++++ frontend-web/src/core/auth/task-deeplink.ts | 18 +- frontend-web/src/core/auth/task-purpose.ts | 183 ++++++++++++++++ frontend-web/src/core/threads/hooks.ts | 16 +- .../src/core/threads/use-task-threads.ts | 29 +++ frontend-web/src/pages/AgentChatPage.tsx | 32 +++ frontend-web/src/pages/LoginPage.tsx | 2 +- frontend-web/src/pages/StrategyRoutes.tsx | 25 ++- .../task-components/TaskFxWorkspace.tsx | 203 ++++++++++++++++++ .../task-components/TaskPurposePanel.tsx | 153 +++++++++++++ .../backend/app/gateway/app.py | 2 + .../app/gateway/routers/public_config.py | 28 +++ .../harness/deerflow/config/app_config.py | 56 ++--- .../deerflow/config/task_deeplink_config.py | 51 +++++ offline-backend-20260512/config.yaml | 41 ++++ 15 files changed, 907 insertions(+), 36 deletions(-) create mode 100644 frontend-web/src/core/auth/task-deeplink-config.ts create mode 100644 frontend-web/src/core/auth/task-purpose.ts create mode 100644 frontend-web/src/core/threads/use-task-threads.ts create mode 100644 frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx create mode 100644 frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/public_config.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts new file mode 100644 index 00000000..f31a74a3 --- /dev/null +++ b/frontend-web/src/core/auth/task-deeplink-config.ts @@ -0,0 +1,104 @@ +/** + * 任务深链(``goPath=rwfx``)的运行时配置:从后端公开只读接口 + * ``GET /api/public/config/task-deeplink`` 读取(地址、测试开关、测试假数据全在后端 + * ``config.yaml`` 的 ``task_deeplink`` 段,改完即时生效,前端无需重建)。 + * + * 两种用法: + * - 命令式(登录深链流程,非 React 环境):``fetchTaskDeeplinkConfig()`` 拉取并模块级缓存; + * ``getCachedTaskDeeplinkConfig()`` 同步读缓存(拼跳转地址用)。 + * - React 组件:``useTaskDeeplinkConfig()``(见 [[task-purpose]] 的面板/列表)。 + * + * 拉取失败 → 用内置默认值(``test=false`` + 真实 URL)兜底,功能不整体挂掉。 + */ + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +export interface TaskDeeplinkConfig { + /** 目的树查询走假数据。 */ + test: boolean; + /** 删除接口走假数据(该接口未开发完时打开)。 */ + deleteTest: boolean; + /** 目的树查询完整 URL(调用追加 ?taskId=)。 */ + purposeDetailUrl: string; + /** 删除完整 URL(调用追加 ?taskId=)。 */ + deleteUrl: string; + /** B 系统树形完整编辑页 URL(跳转追加 ?id=)。 */ + treemapEditUrl: string; + /** B 系统树形下一步页 URL(跳转追加 ?id=)。 */ + treemapNextUrl: string; + /** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}。 */ + mock: { + purposeDetail?: unknown; + delete?: { message?: string; success?: boolean }; + }; +} + +/** 内置默认值(后端拿不到时兜底;真实 URL + 关闭测试)。 */ +export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = { + test: false, + deleteTest: false, + purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail", + deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete", + treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit", + treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next", + mock: {}, +}; + +/** 后端 snake_case 响应 → 前端 camelCase;缺字段回退默认值。 */ +function normalize(body: unknown): TaskDeeplinkConfig { + const o = body && typeof body === "object" ? (body as Record) : {}; + const d = DEFAULT_TASK_DEEPLINK_CONFIG; + const str = (v: unknown, fallback: string) => + typeof v === "string" && v.trim() ? v : fallback; + const mock = o.mock && typeof o.mock === "object" ? (o.mock as Record) : {}; + return { + test: o.test === true, + deleteTest: o.delete_test === true, + purposeDetailUrl: str(o.purpose_detail_url, d.purposeDetailUrl), + deleteUrl: str(o.delete_url, d.deleteUrl), + treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl), + treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl), + mock: { + purposeDetail: mock.purpose_detail, + delete: + mock.delete && typeof mock.delete === "object" + ? (mock.delete as { message?: string; success?: boolean }) + : undefined, + }, + }; +} + +let cached: TaskDeeplinkConfig | null = null; +let inflight: Promise | null = null; + +/** 拉取(带模块级缓存 + 去重);失败回退默认值。 */ +export async function fetchTaskDeeplinkConfig(): Promise { + if (cached) return cached; + if (inflight) return inflight; + inflight = (async () => { + try { + const res = await apiFetch(`${getBackendBaseURL()}/api/public/config/task-deeplink`); + if (!res.ok) throw new Error(`task-deeplink config ${res.status}`); + cached = normalize(await res.json()); + } catch { + cached = { ...DEFAULT_TASK_DEEPLINK_CONFIG }; + } finally { + inflight = null; + } + return cached; + })(); + return inflight; +} + +/** 同步读缓存(未拉取过则返回默认值)。 */ +export function getCachedTaskDeeplinkConfig(): TaskDeeplinkConfig { + return cached ?? DEFAULT_TASK_DEEPLINK_CONFIG; +} + +/** 在完整 URL(可能含 `#` 片段)后追加查询参数,供跳转/调用拼 id/taskId。 */ +export function appendQueryParam(url: string, key: string, value: string): string { + if (!url) return ""; + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}${key}=${encodeURIComponent(value)}`; +} diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 603e75c4..54937c92 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -16,8 +16,12 @@ */ import { requestCrudService } from "@/service/request-axios-service"; +import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose"; import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; +/** rwfx 业务码:单智能体走「任务工作区 + 目的树闸门」(其它业务保持原问答逻辑)。 */ +const RWFX_BUSINESS_CODE = "6BF"; + /** consumer 接口返回的任务详情(data 字段)。 */ export interface CopTaskDetail { id: string | number | null; @@ -132,12 +136,16 @@ function buildOpeningText(detail: CopTaskDetail): string { export async function resolveTaskDeeplinkTarget( goPath: string, detail: CopTaskDetail, + taskId: string, ): Promise { const parsed = parseGoPath(goPath); if (!parsed) return null; const taskBase = BUSINESS_TASK_BASE[parsed.businessCode]; if (!taskBase) return null; + // 清掉上一次深链可能残留的任务上下文(仅 rwfx 单智能体会重新写入)。 + clearTaskContext(); + if (parsed.roundtable) { // 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。 return { @@ -162,6 +170,15 @@ export async function resolveTaskDeeplinkTarget( } if (!agentId) return null; + if (parsed.businessCode === RWFX_BUSINESS_CODE) { + // rwfx:走「任务工作区」——暂存任务上下文(taskId + 开场信息),跳到 chats 索引, + // 由工作区按 taskId 决定开最新历史对话 / 新建(新建走目的树闸门)。不设 pendingAgentMessage: + // 开场信息在闸门通过后由目的树面板发出。 + persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail) }); + return { path: `${taskBase}/agent/${agentId}/chats` }; + } + + // 其它单智能体问答:保持原逻辑(pendingAgentMessage 自动发出 + chats/new)。 try { sessionStorage.setItem( "pendingAgentMessage", @@ -170,6 +187,5 @@ export async function resolveTaskDeeplinkTarget( } catch { /* noop */ } - // 单智能体问答:同样嵌进对应业务的「任务管理」布局。 return { path: `${taskBase}/agent/${agentId}/chats/new` }; } diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts new file mode 100644 index 00000000..5ba824ae --- /dev/null +++ b/frontend-web/src/core/auth/task-purpose.ts @@ -0,0 +1,183 @@ +/** + * 任务深链(``goPath=rwfx``)的「目的树闸门 + 任务↔对话绑定」支持。 + * + * 角色区分: + * - **目的树数据**:B 系统里的策划结果,走 ``getTaskPurposeDetail`` / ``delete``(地址、测试 + * 开关、测试假数据都来自 [[task-deeplink-config]] / 后端 config.yaml),与聊天对话无关。 + * 新建对话(含首次进入)前用它做「已有数据→覆盖确认」闸门。 + * - **对话(thread)**:聊天会话;通过 thread 的 ``metadata.taskId`` 标签把多个对话挂到同一个 + * 任务下(1:N),列表用 useThreadsPage({ metadata:{ taskId } }) 过滤。详见 [[task-deeplink]]。 + */ + +import { + appendQueryParam, + fetchTaskDeeplinkConfig, + getCachedTaskDeeplinkConfig, +} from "@/core/auth/task-deeplink-config"; +import { requestCrudService } from "@/service/request-axios-service"; + +/** 归一化后的目的树节点(用于展示,逐层:目的→行为体→预期行为→驱动因素→叙事)。 */ +export interface PurposeTreeNode { + /** 节点类型中文标签。 */ + label: string; + /** 节点主文本(目的/行为体名/预期/因素/叙事内容)。 */ + text: string; + /** 推荐理由(若有)。 */ + reason?: string; + children: PurposeTreeNode[]; +} + +/** 当前 rwfx 任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */ +export interface TaskContext { + taskId: string; + agentId: string; + /** 自动发出的开场信息(任务名称/内容)。 */ + openingText: string; +} + +const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext"; + +/* --------------------------- 目的树查询/删除 --------------------------- */ + +/** 逐层 dto 字段映射:key=childrenList 里出现的 *Dto 字段名。 */ +const DTO_FIELD_MAP: Record = { + purposeDto: { label: "目的", textKey: "zzPurpose" }, + actorDto: { label: "行为体", textKey: "actorName" }, + expectedBehaviorDto: { label: "预期行为", textKey: "behavior" }, + drivingFactorDto: { label: "驱动因素", textKey: "drivingFactor" }, + narrationDto: { label: "叙事", textKey: "narration" }, +}; + +function normalizeNode(raw: unknown): PurposeTreeNode | null { + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + // 找出本节点携带的 dto 字段(目的/行为体/...)。 + const dtoKey = Object.keys(DTO_FIELD_MAP).find( + (k) => obj[k] && typeof obj[k] === "object", + ); + if (!dtoKey) return null; + const { label, textKey } = DTO_FIELD_MAP[dtoKey]; + const dto = obj[dtoKey] as Record; + const text = typeof dto[textKey] === "string" ? (dto[textKey] as string) : ""; + const reason = + typeof dto.recReason === "string" && dto.recReason.trim() + ? (dto.recReason as string) + : undefined; + const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : []; + const children = childrenRaw + .map(normalizeNode) + .filter((n): n is PurposeTreeNode => n !== null); + return { label, text, reason, children }; +} + +/** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */ +function bodyToTree(body: unknown): PurposeTreeNode[] { + const data = + body && typeof body === "object" ? (body as { data?: unknown }).data : null; + const arr = Array.isArray(data) ? data : []; + return arr.map(normalizeNode).filter((n): n is PurposeTreeNode => n !== null); +} + +export interface TaskPurposeResult { + tree: PurposeTreeNode[]; + /** data 数组里有有效目的节点 = 已有策划数据。 */ + hasData: boolean; +} + +/** + * 查询任务目的树。``test=true`` 时直接用后端配置里的假数据;否则打真实接口 + * (地址来自配置,``Authorization: Bearer admin`` 由 axios 拦截器统一注入)。 + * 缺地址 / 返回不合法 → ``{ tree: [], hasData: false }``。 + */ +export async function fetchTaskPurposeDetail( + taskId: string, +): Promise { + if (!taskId) return { tree: [], hasData: false }; + const cfg = await fetchTaskDeeplinkConfig(); + let body: unknown; + if (cfg.test) { + body = cfg.mock.purposeDetail; + } else if (cfg.purposeDetailUrl) { + body = await requestCrudService.get(cfg.purposeDetailUrl, { taskId }); + } else { + return { tree: [], hasData: false }; + } + const tree = bodyToTree(body); + return { tree, hasData: tree.length > 0 }; +} + +export interface DeleteTaskPurposeResult { + message: string; + success: boolean; +} + +/** + * 删除任务目的树(覆盖前清空 / 手动清除任务记录都走这里)。``delete_test=true`` 时 + * 用后端配置里的假数据(接口未开发完时用);否则打真实接口。 + */ +export async function deleteTaskPurpose( + taskId: string, +): Promise { + if (!taskId) return { message: "缺少 taskId", success: false }; + const cfg = await fetchTaskDeeplinkConfig(); + if (cfg.deleteTest) { + const m = cfg.mock.delete ?? {}; + return { message: typeof m.message === "string" ? m.message : "", success: m.success === true }; + } + if (!cfg.deleteUrl) return { message: "缺少删除接口地址", success: false }; + const body = await requestCrudService.get(cfg.deleteUrl, { taskId }); + const obj = + body && typeof body === "object" ? (body as unknown as Record) : {}; + return { + message: typeof obj.message === "string" ? obj.message : "", + success: obj.success === true, + }; +} + +/* ----------------------------- 跳转地址 ----------------------------- */ + +/** B 系统「树形完整编辑」页地址(问答后「编辑」按钮,新标签打开)。用同步缓存配置。 */ +export function buildTreemapEditUrl(taskId: string): string { + const url = getCachedTaskDeeplinkConfig().treemapEditUrl; + return url && taskId ? appendQueryParam(url, "id", taskId) : ""; +} + +/** B 系统「树形下一步」页地址(问答后「下一步」按钮,新标签打开)。 */ +export function buildTreemapNextUrl(taskId: string): string { + const url = getCachedTaskDeeplinkConfig().treemapNextUrl; + return url && taskId ? appendQueryParam(url, "id", taskId) : ""; +} + +/* --------------------------- 任务上下文暂存 --------------------------- */ + +export function persistTaskContext(ctx: TaskContext): void { + try { + sessionStorage.setItem(TASK_CONTEXT_STORAGE_KEY, JSON.stringify(ctx)); + } catch { + /* noop */ + } +} + +export function readTaskContext(): TaskContext | null { + try { + const raw = sessionStorage.getItem(TASK_CONTEXT_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed?.taskId || !parsed?.agentId) return null; + return { + taskId: String(parsed.taskId), + agentId: String(parsed.agentId), + openingText: typeof parsed.openingText === "string" ? parsed.openingText : "", + }; + } catch { + return null; + } +} + +export function clearTaskContext(): void { + try { + sessionStorage.removeItem(TASK_CONTEXT_STORAGE_KEY); + } catch { + /* noop */ + } +} diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 687529cc..0cee3331 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -34,6 +34,9 @@ export type ThreadStreamOptions = { context: LocalSettings["context"]; isMock?: boolean; assistantId?: string; + /** 额外写到新建 thread 的 metadata(与 agent_id 合并)。 + * 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */ + threadMetadata?: Record; onStart?: (threadId: string) => void; onFinish?: (state: AgentThreadState) => void; onToolEnd?: (event: ToolEndEvent) => void; @@ -200,6 +203,7 @@ export function useThreadStream({ context, isMock, assistantId, + threadMetadata, onStart, onFinish, onToolEnd, @@ -277,11 +281,15 @@ export function useThreadStream({ handleStreamStart(meta.thread_id); setOnStreamThreadId(meta.thread_id); const metadataAgentId = context.agent_id ?? context.agent_name; - if (metadataAgentId && !isMock) { + const extraMetadata = + threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {}; + const metadata: Record = { + ...(metadataAgentId ? { agent_id: metadataAgentId } : {}), + ...extraMetadata, + }; + if (Object.keys(metadata).length > 0 && !isMock) { void getAPIClient() - .threads.update(meta.thread_id, { - metadata: { agent_id: metadataAgentId }, - }) + .threads.update(meta.thread_id, { metadata }) .catch(() => ({})); } }, diff --git a/frontend-web/src/core/threads/use-task-threads.ts b/frontend-web/src/core/threads/use-task-threads.ts new file mode 100644 index 00000000..cad4b9f1 --- /dev/null +++ b/frontend-web/src/core/threads/use-task-threads.ts @@ -0,0 +1,29 @@ +/** + * 「一个任务挂多个对话」(1:N):按 thread 的 ``metadata.taskId`` 过滤出某任务下的对话列表。 + * + * 新建对话时由 [[task-purpose]] 流程给 thread 打 ``metadata.taskId`` 标签 + * (见 useThreadStream 的 ``threadMetadata``);这里复用服务端分页搜索按该标签过滤, + * 后端默认按 ``updated_at desc`` 排序,所以 ``threads[0]`` 即最新一条。 + */ + +import { useThreadsPage } from "@/core/threads/hooks"; +import type { AgentThread } from "@/core/threads/types"; + +export function useTaskThreads(taskId: string | null | undefined) { + const enabled = !!taskId; + // 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。 + const query = useThreadsPage({ + page: 1, + pageSize: 100, + metadata: enabled ? { taskId } : { taskId: "__none__" }, + }); + const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : []; + return { + ...query, + threads, + /** 最新对话(按 updated_at desc,列表第一条)。 */ + latestThreadId: threads[0]?.thread_id ?? null, + /** 是否已有历史对话(决定深链进入后默认开最新还是走新建闸门)。 */ + hasThreads: threads.length > 0, + }; +} diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index fc485f41..eaad5512 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -29,11 +29,14 @@ import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; +import { readTaskContext } from "@/core/auth/task-purpose"; +import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel"; import { desensitize } from "@/lib/desensitize"; export default function AgentChatPage({ embeddedAgentId, chatBasePath, + taskGate, }: { /** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。 * 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */ @@ -43,6 +46,9 @@ export default function AgentChatPage({ * 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`, * 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */ chatBasePath?: string; + /** rwfx 任务深链闸门:新建对话时顶部展示目的树面板(覆盖确认后才开始问答), + * 并给新建 thread 打 `metadata.taskId` 标签(任务↔对话 1:N)。见 task-purpose。 */ + taskGate?: boolean; } = {}) { const routeBase = useRouteBase(); const { t } = useI18n(); @@ -57,6 +63,16 @@ export default function AgentChatPage({ // 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。 const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`; + // rwfx 任务闸门:读取登录深链/「新建对话」暂存的任务上下文(taskId + 开场信息)。 + const taskContext = useMemo( + () => (taskGate ? readTaskContext() : null), + [taskGate], + ); + const taskId = + taskGate && taskContext && taskContext.agentId === agent_id + ? taskContext.taskId + : ""; + // If ChatPage stashed an opening message for this agent, skip the landing // screen on first render so the user doesn't see a flash of the welcome UI // before the auto-sent message takes over. @@ -101,6 +117,8 @@ export default function AgentChatPage({ threadId: isNewThread ? undefined : threadId, assistantId: agent_id, context: { ...settings.context, agent_id }, + // 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤。 + threadMetadata: taskId ? { taskId } : undefined, onStart: (createdThreadId) => { setThreadId(createdThreadId); setIsNewThread(false); @@ -143,6 +161,14 @@ export default function AgentChatPage({ [sendMessage, threadId, agent_id], ); + // 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则 + // 仅切到对话态(隐藏面板),让用户手动输入。 + const handleGateStart = useCallback(() => { + const text = taskContext?.openingText?.trim(); + if (text) handleSubmit({ text, files: [] }); + else setHasStartedConversation(true); + }, [handleSubmit, taskContext]); + // ChatPage stashes the opening message in sessionStorage when the user picks // an agent on the landing screen, then redirects here. Replay it once so the // user doesn't have to re-type the question on the agent chat page. @@ -181,6 +207,8 @@ export default function AgentChatPage({ }, [thread]); const isLandingState = isNewThread && !hasStartedConversation; + // 任务闸门面板:仅在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。 + const showTaskGate = !!taskId && isNewThread && !hasStartedConversation; const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; const status = thread.error @@ -245,6 +273,10 @@ export default function AgentChatPage({ + {showTaskGate && ( + + )} + {isLandingState ? ( /* Home: centered hero, suggested questions, and input. Everything lives inside the scrollable main column. No fixed, diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 5b495ea0..a8b91c68 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -164,7 +164,7 @@ export default function LoginPage() { if (cancelled) return; if (detail) { persistCopTaskDetail(detail); - const target = await resolveTaskDeeplinkTarget(goPath, detail); + const target = await resolveTaskDeeplinkTarget(goPath, detail, taskId); if (cancelled) return; if (target) { navigate(target.path, { replace: true, state: target.state }); diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 2e17bcdf..269efcec 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -11,6 +11,11 @@ import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgF import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyFrom"; import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom"; import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes"; +import { + TaskFxChat, + TaskFxIndexResolver, + TaskFxWorkspace, +} from "@/strategy-components/components/task-components/TaskFxWorkspace"; import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes"; import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement"; import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement"; @@ -162,12 +167,26 @@ export default function StrategyRoutes() { } /> + {/* 任务FX:任务工作区(左对话列表 1:N + 右问答;新建对话顶部目的树闸门)。 */} + } - /> + > + + } + /> + + } + /> + 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。 + * + * - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。 + * - 新建对话 = 直接跳入:navigate 到 chats/new,AgentChatPage 闸门态展示目的树面板。 + * - 清除任务记录:仅调 delete 接口(聊天对话保留)。 + * - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。 + * 见 [[task-purpose]] / [[task-deeplink-config]]。 + */ + +import { useEffect, useState } from "react"; +import { Outlet, useNavigate, useParams } from "react-router-dom"; +import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + buildTreemapEditUrl, + buildTreemapNextUrl, + deleteTaskPurpose, + readTaskContext, +} from "@/core/auth/task-purpose"; +import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config"; +import { useTaskThreads } from "@/core/threads/use-task-threads"; +import { titleOfThread } from "@/core/threads/utils"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; +import AgentChatPage from "@/pages/AgentChatPage"; +import { ChatRuntime } from "@/pages/WorkspaceRoutes"; + +function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } { + const { agent_id } = useParams<{ agent_id: string }>(); + const taskId = readTaskContext()?.taskId ?? ""; + return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` }; +} + +/** 左侧任务对话列表 + 任务级操作。 */ +function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) { + const { thread_id } = useParams<{ thread_id: string }>(); + const navigate = useNavigate(); + const { threads, isLoading } = useTaskThreads(taskId); + const [clearOpen, setClearOpen] = useState(false); + const [clearing, setClearing] = useState(false); + + const openExternal = (url: string, label: string) => { + if (url) window.open(url, "_blank", "noopener,noreferrer"); + else toast.error(`未配置${label}地址`); + }; + + const handleClear = async () => { + setClearing(true); + try { + const r = await deleteTaskPurpose(taskId); + if (r.success) toast.success("已清除任务记录"); + else toast.error(r.message || "清除失败"); + } catch { + toast.error("清除失败,请稍后再试"); + } finally { + setClearing(false); + setClearOpen(false); + } + }; + + return ( +
+
+ +
+ + +
+ +
+ +
+ {isLoading ? ( +
加载中…
+ ) : threads.length === 0 ? ( +
暂无对话
+ ) : ( + threads.map((thread) => ( + + )) + )} +
+ + !clearing && setClearOpen(o)}> + + + 清除任务记录 + + 将清除该任务在 B 系统中的目的策划数据(聊天对话保留),是否确认? + + + + + + + + +
+ ); +} + +/** 任务工作区布局:左列表 + 右 Outlet。 */ +export function TaskFxWorkspace({ taskBase }: { taskBase: string }) { + const { taskId, chatsBase } = useChatsBase(taskBase); + // 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。 + useEffect(() => { + void fetchTaskDeeplinkConfig(); + }, []); + return ( +
+ +
+ +
+
+ ); +} + +/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(走闸门)。 */ +export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) { + const { taskId, chatsBase } = useChatsBase(taskBase); + const navigate = useNavigate(); + const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId); + + useEffect(() => { + if (isLoading) return; + if (hasThreads && latestThreadId) { + navigate(`${chatsBase}/${latestThreadId}`, { replace: true }); + } else { + navigate(`${chatsBase}/new`, { replace: true }); + } + }, [isLoading, hasThreads, latestThreadId, chatsBase, navigate]); + + return ( +
+ +
+ ); +} + +/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板)。 */ +export function TaskFxChat({ taskBase }: { taskBase: string }) { + const { agent_id } = useParams<{ agent_id: string }>(); + return ( + + + + ); +} diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx new file mode 100644 index 00000000..aaea6f2a --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx @@ -0,0 +1,153 @@ +/** + * 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。 + * + * 流程:拉目的树并展示 → 点「开始问答」: + * - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart(); + * success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。 + * - 无数据 → 直接 onStart()。 + * onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。 + */ + +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + deleteTaskPurpose, + fetchTaskPurposeDetail, + type PurposeTreeNode, + type TaskPurposeResult, +} from "@/core/auth/task-purpose"; + +function PurposeNodes({ nodes, depth = 0 }: { nodes: PurposeTreeNode[]; depth?: number }) { + return ( +
    + {nodes.map((node, i) => ( +
  • + + {node.label} + + {node.text || "(空)"} + {node.reason ? ( + 理由:{node.reason} + ) : null} + {node.children.length > 0 ? ( + + ) : null} +
  • + ))} +
+ ); +} + +export function TaskPurposePanel({ + taskId, + onStart, +}: { + taskId: string; + /** 闸门通过(无数据 / 覆盖成功),开始问答。 */ + onStart: () => void; +}) { + const [loading, setLoading] = useState(true); + const [result, setResult] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [working, setWorking] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + fetchTaskPurposeDetail(taskId) + .then((r) => { + if (!cancelled) setResult(r); + }) + .catch(() => { + if (!cancelled) setResult({ tree: [], hasData: false }); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [taskId]); + + const handleStart = () => { + if (result?.hasData) { + setConfirmOpen(true); // 有数据 → 先确认覆盖 + } else { + onStart(); // 无数据 → 直接开始 + } + }; + + const handleConfirmOverwrite = async () => { + setWorking(true); + try { + const r = await deleteTaskPurpose(taskId); + if (r.success) { + setConfirmOpen(false); + onStart(); + } else { + toast.error(r.message || "无法覆盖,请稍后再试"); + setConfirmOpen(false); + } + } catch { + toast.error("覆盖失败,请稍后再试"); + setConfirmOpen(false); + } finally { + setWorking(false); + } + }; + + return ( +
+
+ 任务目的(任务 {taskId}) + +
+ + {loading ? ( +
+ 正在查询任务目的… +
+ ) : result && result.tree.length > 0 ? ( +
+ +
+ ) : ( +
暂无目的数据,可直接开始问答。
+ )} + + !working && setConfirmOpen(o)}> + + + 确认覆盖 + + 检测到该任务已有目的数据,继续问答将覆盖原有数据,是否确认? + + + + + + + + +
+ ); +} + +export default TaskPurposePanel; diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index bae7f335..b0a61656 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -51,6 +51,7 @@ from app.gateway.routers import ( parallel_agent_tasks, parallel_agents, positions, + public_config, public_embed, public_leaderboard, public_scheduled_tasks, @@ -1368,6 +1369,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Public embed Q&A status polling (/api/public/embed/*) app.include_router(public_embed.router) + app.include_router(public_config.router) app.include_router(html_page_favorites.router) # Product knowledge base API is mounted at /api/knowledge diff --git a/offline-backend-20260512/backend/app/gateway/routers/public_config.py b/offline-backend-20260512/backend/app/gateway/routers/public_config.py new file mode 100644 index 00000000..861a2d8a --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/public_config.py @@ -0,0 +1,28 @@ +"""公开只读配置接口(前端运行时读取 config.yaml 派生值)。 + +目前只暴露 ``task_deeplink`` 段(任务深链 rwfx 的目的树闸门地址 + 测试开关 + +测试假数据),供前端登录深链流程在运行时读取——无需把这些地址硬编码进前端, +改 ``config.yaml`` 即时生效(Gateway mtime 热重载)。 + +**无鉴权**:路由挂在 ``/api/public/`` 前缀下,已被 +``auth_middleware._PUBLIC_PATH_PREFIXES`` 白名单放行。段内只有地址与开关,不含密钥。 +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter + +from deerflow.config import get_app_config + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/public/config", tags=["public-config"]) + + +@router.get("/task-deeplink") +async def get_task_deeplink_config() -> dict: + """返回 ``config.yaml`` 的 ``task_deeplink`` 段(含 test/delete_test/各 URL/mock)。""" + config = get_app_config() + return config.task_deeplink.model_dump() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index cb277ee0..b86b549e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -15,10 +15,10 @@ from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpo from deerflow.config.database_config import DatabaseConfig from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.gateway_config import GatewayConfig -from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict -from deerflow.config.knowledge_config import KnowledgeConfig -from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig -from deerflow.config.log_system_config import LogSystemConfig +from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict +from deerflow.config.knowledge_config import KnowledgeConfig +from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig +from deerflow.config.log_system_config import LogSystemConfig from deerflow.config.login_config import AuthLoginConfig from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.model_config import ModelConfig @@ -33,6 +33,7 @@ from deerflow.config.skills_config import SkillsConfig from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict from deerflow.config.summarization_config import SummarizationConfig, load_summarization_config_from_dict +from deerflow.config.task_deeplink_config import TaskDeeplinkConfig from deerflow.config.title_config import TitleConfig, load_title_config_from_dict from deerflow.config.token_usage_config import TokenUsageConfig from deerflow.config.tool_config import ToolConfig, ToolGroupConfig @@ -136,9 +137,10 @@ class AppConfig(BaseModel): run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") - browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration") - knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration") - knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration") + browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration") + knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration") + knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration") + task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)") @classmethod def resolve_config_path(cls, config_path: str | None = None) -> Path: @@ -387,26 +389,26 @@ def _get_config_mtime(config_path: Path) -> float | None: return None -def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig: - """Load config from disk and refresh cache metadata.""" - global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom - - resolved_path = AppConfig.resolve_config_path(config_path) - _app_config = AppConfig.from_file(str(resolved_path)) - _app_config_path = resolved_path - _app_config_mtime = _get_config_mtime(resolved_path) - _app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime() - _app_config_is_custom = False - summarization = _app_config.summarization - logger.info( - "Loaded AppConfig from %s; summarization enabled=%s trigger=%s keep=%s trim_tokens_to_summarize=%s", - resolved_path, - summarization.enabled, - summarization.trigger, - summarization.keep, - summarization.trim_tokens_to_summarize, - ) - return _app_config +def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig: + """Load config from disk and refresh cache metadata.""" + global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom + + resolved_path = AppConfig.resolve_config_path(config_path) + _app_config = AppConfig.from_file(str(resolved_path)) + _app_config_path = resolved_path + _app_config_mtime = _get_config_mtime(resolved_path) + _app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime() + _app_config_is_custom = False + summarization = _app_config.summarization + logger.info( + "Loaded AppConfig from %s; summarization enabled=%s trigger=%s keep=%s trim_tokens_to_summarize=%s", + resolved_path, + summarization.enabled, + summarization.trigger, + summarization.keep, + summarization.trim_tokens_to_summarize, + ) + return _app_config def get_app_config() -> AppConfig: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py new file mode 100644 index 00000000..5ebc2f13 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py @@ -0,0 +1,51 @@ +"""任务深链(``goPath=rwfx``)的「目的树闸门 + 跳转」配置。 + +前端(登录深链流程)通过公开只读接口 ``GET /api/public/config/task-deeplink`` +读取这一段:所有外部地址都是**完整 URL**(调用时仅追加 ``?taskId=`` / ``?id=``), +随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。 + +测试开关(线上接口未通时用假数据,互相独立): + - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。 + - ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。 +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class TaskDeeplinkConfig(BaseModel): + """rwfx 任务深链的目的树查询 / 删除接口与跳转页地址。""" + + model_config = ConfigDict(extra="allow") + + test: bool = Field( + default=False, + description="为真时前端目的树查询走 mock.purpose_detail 假数据(不打真实接口)", + ) + delete_test: bool = Field( + default=False, + description="为真时前端删除接口走 mock.delete 假数据(删除接口未开发完时打开)", + ) + purpose_detail_url: str = Field( + default="", + description="目的树查询完整 URL,调用时追加 ?taskId=", + ) + delete_url: str = Field( + default="", + description="删除完整 URL,调用时追加 ?taskId=", + ) + treemap_edit_url: str = Field( + default="", + description="B 系统树形完整编辑页 URL,跳转时追加 ?id=", + ) + treemap_next_url: str = Field( + default="", + description="B 系统树形下一步页 URL,跳转时追加 ?id=", + ) + mock: dict[str, Any] = Field( + default_factory=dict, + description="test/delete_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}}", + ) diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 0bc2744e..94cb54a0 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -324,3 +324,44 @@ summarization: type: tokens value: 40000 trim_tokens_to_summarize: 8000 + +# ─────────────────────────────────────────────────────────────────────────── +# 任务深链(goPath=rwfx):目的树闸门 + 问答后跳转。前端经公开只读接口 +# GET /api/public/config/task-deeplink 读取。地址都是完整 URL(调用时仅追加 +# ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。 +task_deeplink: + # 线上接口未通时用假数据;两个开关互相独立。 + test: true # 目的树查询走 mock.purpose_detail 假数据 + delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) + purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" + delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete" + treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit" + treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next" + mock: + # test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。 + purpose_detail: + state: "200" + msg: "操作成功!" + data: + - purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 } + childrenList: + - actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 } + childrenList: + - expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" } + childrenList: + - drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 } + childrenList: + - narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 } + childrenList: [] + - actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 } + childrenList: + - expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" } + childrenList: + - drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 } + childrenList: + - narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 } + childrenList: [] + # delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。 + delete: + message: "行动已经开始策划,不可覆盖" + success: true From 138a5adb1c6f1ee6ae5f1c700412a8082d429861 Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 17 Jun 2026 16:08:54 +0800 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20=E5=89=8D=E7=AB=AF=20cop-task-deta?= =?UTF-8?q?il=20=E8=B5=B0=20mock=20+=20=E5=90=8E=E7=AB=AF=20task=5Fdeeplin?= =?UTF-8?q?k=20=E5=90=8C=E6=AD=A5=E5=88=B0=20backend/config.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend-web/.env.production: VITE_MOCK_COP_TASK_DETAIL=true(consumer 外网不可达,登录深链任务详情走本地假数据) - backend/config.yaml: 补上 task_deeplink 段(test/delete_test=true 走 mock),与外层 config.yaml 对齐;服务器实际加载的是 backend/config.yaml,否则 /api/public/config/task-deeplink 返回空导致 rwfx 目的树闸门失效 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/.env.production | 2 +- offline-backend-20260512/backend/config.yaml | 37 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 97415742..d1fc2180 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -5,7 +5,7 @@ VITE_USE_REAL_DATA=true # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。 -VITE_MOCK_COP_TASK_DETAIL=false +VITE_MOCK_COP_TASK_DETAIL=true # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index c23bb4ce..953730c2 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -418,3 +418,40 @@ log_system: enabled: true url: 'https://your-log-system.example.com' label: '日志系统' + +task_deeplink: + # 线上接口未通时用假数据;两个开关互相独立。 + test: true # 目的树查询走 mock.purpose_detail 假数据 + delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) + purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" + delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete" + treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit" + treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next" + mock: + # test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。 + purpose_detail: + state: "200" + msg: "操作成功!" + data: + - purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 } + childrenList: + - actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 } + childrenList: + - expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" } + childrenList: + - drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 } + childrenList: + - narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 } + childrenList: [] + - actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 } + childrenList: + - expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" } + childrenList: + - drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 } + childrenList: + - narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 } + childrenList: [] + # delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。 + delete: + message: "行动已经开始策划,不可覆盖" + success: true From ba0ec205324ffc5bcc2da6b24a173c94399bc767 Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 17 Jun 2026 16:10:57 +0800 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20.env.development=20=E4=B9=9F?= =?UTF-8?q?=E5=BC=80=E5=90=AF=20cop-task-detail=20mock(VITE=5FMOCK=5FCOP?= =?UTF-8?q?=5FTASK=5FDETAIL=3Dtrue)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/.env.development | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 22563825..05435bf2 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -5,7 +5,7 @@ VITE_USE_REAL_DATA=true # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。 -VITE_MOCK_COP_TASK_DETAIL=false +VITE_MOCK_COP_TASK_DETAIL=true # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 From a2bb8c80a2c745273d94b6ca1c4096293aa978fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 17 Jun 2026 16:41:37 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E7=9B=AE?= =?UTF-8?q?=E7=9A=84=E6=A0=91=E8=A1=A8=E6=A0=BC=E5=8C=96=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=20+=20=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E8=80=81=E5=AF=B9=E8=AF=9D=E6=9C=8D=E5=8A=A1=E7=AB=AF=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - task-purpose: normalizeNode 容错(未知 *Dto 兜底/文本字段改名兜底);新增 flattenPurposeTree 拍平目的树 - TaskPurposePanel: 目的树改表格展示(列数自适应),支持弹框放大查看 - threads/hooks: 新增 stopThreadRun,真正取消服务端 run(非仅断开本地 SSE) - AgentChatPage: taskGate 下切换/卸载会话时停掉老对话服务端 run - TaskFxWorkspace: 操作按钮(编辑/下一步/清除)改右下角常驻红色浮层 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/core/auth/task-purpose.ts | 66 ++++++-- frontend-web/src/core/threads/hooks.ts | 33 ++++ frontend-web/src/pages/AgentChatPage.tsx | 18 ++- .../task-components/TaskFxWorkspace.tsx | 141 ++++++++++-------- .../task-components/TaskPurposePanel.tsx | 121 +++++++++++---- 5 files changed, 273 insertions(+), 106 deletions(-) diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts index 5ba824ae..b1a0bc3a 100644 --- a/frontend-web/src/core/auth/task-purpose.ts +++ b/frontend-web/src/core/auth/task-purpose.ts @@ -48,28 +48,70 @@ const DTO_FIELD_MAP: Record = { narrationDto: { label: "叙事", textKey: "narration" }, }; +// 取节点主文本时跳过的字段(id/时间/标记/理由等非展示文本),保证容错。 +const SKIP_TEXT_KEY = /(^id$|Id$|Time$|Flag$|Type$|Level$|recReason|createBy|updateBy|^mid$)/i; + +/** 从一个 dto 里取展示文本:优先 preferredKey,否则取第一个像「内容」的字符串字段。 */ +function pickDtoText(dto: Record, preferredKey?: string): string { + if (preferredKey && typeof dto[preferredKey] === "string" && (dto[preferredKey] as string).trim()) { + return dto[preferredKey] as string; + } + for (const [k, v] of Object.entries(dto)) { + if (typeof v === "string" && v.trim() && !SKIP_TEXT_KEY.test(k)) return v; + } + return ""; +} + +/** + * 把一层节点归一化为 {label,text,reason,children}。**容错**: + * - 优先匹配已知 dto(目的/行为体/...);未知 ``*Dto`` 字段也兜底(标签取字段名去 Dto); + * - 文本字段改名也能取到(pickDtoText 兜底); + * - 完全不认识的结构仍递归 childrenList,避免整棵树丢失。 + */ function normalizeNode(raw: unknown): PurposeTreeNode | null { if (!raw || typeof raw !== "object") return null; const obj = raw as Record; - // 找出本节点携带的 dto 字段(目的/行为体/...)。 - const dtoKey = Object.keys(DTO_FIELD_MAP).find( - (k) => obj[k] && typeof obj[k] === "object", - ); - if (!dtoKey) return null; - const { label, textKey } = DTO_FIELD_MAP[dtoKey]; - const dto = obj[dtoKey] as Record; - const text = typeof dto[textKey] === "string" ? (dto[textKey] as string) : ""; - const reason = - typeof dto.recReason === "string" && dto.recReason.trim() - ? (dto.recReason as string) - : undefined; + const isObj = (k: string) => obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k]); + // 先找已知 dto,再兜底任意 `*Dto` 字段。 + let dtoKey = Object.keys(DTO_FIELD_MAP).find(isObj); + if (!dtoKey) dtoKey = Object.keys(obj).find((k) => /Dto$/.test(k) && isObj(k)); + const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : []; const children = childrenRaw .map(normalizeNode) .filter((n): n is PurposeTreeNode => n !== null); + + if (!dtoKey) { + // 不认识的结构:仅当有子节点时保留为占位节点,否则丢弃。 + return children.length ? { label: "节点", text: "", children } : null; + } + + const mapped = DTO_FIELD_MAP[dtoKey]; + const dto = obj[dtoKey] as Record; + const label = mapped?.label ?? dtoKey.replace(/Dto$/, ""); + const text = pickDtoText(dto, mapped?.textKey); + const reason = + typeof dto.recReason === "string" && (dto.recReason as string).trim() + ? (dto.recReason as string) + : undefined; return { label, text, reason, children }; } +/** 表格行:根→叶一条路径上的各层单元格(层级数可变,做了容错)。 */ +export type PurposeFlatCell = { label: string; text: string; reason?: string }; + +/** 把目的树拍平成「一行一条叶子路径」,供表格展示(层级缺失/变动都不崩)。 */ +export function flattenPurposeTree(nodes: PurposeTreeNode[]): PurposeFlatCell[][] { + const rows: PurposeFlatCell[][] = []; + const walk = (node: PurposeTreeNode, trail: PurposeFlatCell[]) => { + const next = [...trail, { label: node.label, text: node.text, reason: node.reason }]; + if (!node.children || node.children.length === 0) rows.push(next); + else node.children.forEach((c) => walk(c, next)); + }; + nodes.forEach((n) => walk(n, [])); + return rows; +} + /** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */ function bodyToTree(body: unknown): PurposeTreeNode[] { const data = diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 0cee3331..6d34117b 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -121,6 +121,39 @@ function getRunMetadataStorage(): { }; } +/** + * 显式停止某会话「服务端正在跑」的 run。 + * + * 注意:useStream / `thread.stop()` 只中断本地 SSE 监听,服务端 run 仍会继续跑。 + * 任务流程要求「开启新对话时老对话要停止」,所以这里读出 SDK 存在 sessionStorage + * (``lg:stream:{threadId}``)里的 run id,调用 ``runs.cancel`` 真正取消后端 run, + * 再清掉本地 reconnect 记录。已结束 / 不存在的 run 取消失败时静默忽略。 + */ +export async function stopThreadRun( + threadId: string | null | undefined, +): Promise { + if (!threadId || typeof window === "undefined") return; + let runId: string | null = null; + try { + runId = normalizeStoredRunId( + window.sessionStorage.getItem(`lg:stream:${threadId}`), + ); + } catch { + runId = null; + } + if (!runId) return; + try { + await getAPIClient().runs.cancel(threadId, runId); + } catch { + /* 已结束 / 不存在 / 网络错误等忽略 */ + } + try { + window.sessionStorage.removeItem(`lg:stream:${threadId}`); + } catch { + /* noop */ + } +} + function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index eaad5512..2d7372cd 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -26,7 +26,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { saveLastAgentId, useThreadSettings } from "@/core/settings"; -import { useThreadStream } from "@/core/threads/hooks"; +import { stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; import { readTaskContext } from "@/core/auth/task-purpose"; @@ -161,6 +161,22 @@ export default function AgentChatPage({ [sendMessage, threadId, agent_id], ); + // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。 + // 仅 taskGate(rwfx 任务工作区)启用——普通问答页保留后台续跑行为不变。 + const prevTaskThreadRef = useRef(null); + useEffect(() => { + if (!taskGate) return; + const prev = prevTaskThreadRef.current; + if (prev && prev !== threadId) void stopThreadRun(prev); + prevTaskThreadRef.current = threadId ?? null; + }, [taskGate, threadId]); + useEffect(() => { + // 离开任务问答页(卸载)时也停掉当前会话的服务端 run。 + return () => { + if (taskGate) void stopThreadRun(prevTaskThreadRef.current); + }; + }, [taskGate]); + // 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则 // 仅切到对话态(隐藏面板),让用户手动输入。 const handleGateStart = useCallback(() => { diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index 7eaa3e10..e251a68b 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -1,19 +1,19 @@ /** * 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。 * - * 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+ 任务级操作 - * (新建对话 / 编辑 / 下一步 / 清除任务记录); + * 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」; + * 右下角 = 常驻浮层红色按钮(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走; * 右侧 = 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。 * * - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。 - * - 新建对话 = 直接跳入:navigate 到 chats/new,AgentChatPage 闸门态展示目的树面板。 + * - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。 * - 清除任务记录:仅调 delete 接口(聊天对话保留)。 * - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。 * 见 [[task-purpose]] / [[task-deeplink-config]]。 */ -import { useEffect, useState } from "react"; -import { Outlet, useNavigate, useParams } from "react-router-dom"; +import { useEffect, useMemo, useState } from "react"; +import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom"; import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -46,11 +46,59 @@ function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } { return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` }; } -/** 左侧任务对话列表 + 任务级操作。 */ +/** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */ +function parseThreadId(pathname: string, chatsBase: string): string | null { + if (!pathname.startsWith(chatsBase)) return null; + const seg = pathname.slice(chatsBase.length).replace(/^\/+/, "").split("/")[0]; + return seg && seg !== "new" ? seg : null; +} + +/** 左侧任务对话列表 +「新建对话」。 */ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) { - const { thread_id } = useParams<{ thread_id: string }>(); + const { pathname } = useLocation(); const navigate = useNavigate(); const { threads, isLoading } = useTaskThreads(taskId); + const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]); + + return ( +
+
+ +
+ +
+ {isLoading ? ( +
加载中…
+ ) : threads.length === 0 ? ( +
暂无对话
+ ) : ( + threads.map((thread) => ( + + )) + )} +
+
+ ); +} + +/** + * 右下角常驻红色操作浮层:编辑 / 下一步 / 清除任务记录。 + * absolute 定位在工作区右侧面板内,z-40,时刻可见、不被聊天内容挤走。 + */ +function TaskFloatingActions({ taskId }: { taskId: string }) { const [clearOpen, setClearOpen] = useState(false); const [clearing, setClearing] = useState(false); @@ -74,33 +122,28 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas }; return ( -
-
- -
- - -
+ <> +
+ +
-
- {isLoading ? ( -
加载中…
- ) : threads.length === 0 ? ( -
暂无对话
- ) : ( - threads.map((thread) => ( - - )) - )} -
- !clearing && setClearOpen(o)}> @@ -143,17 +163,17 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas - -
+ ); } -/** 任务工作区布局:左列表 + 右 Outlet。 */ +/** 任务工作区布局:左列表 + 右 Outlet + 右下角常驻红色操作浮层。 */ export function TaskFxWorkspace({ taskBase }: { taskBase: string }) { const { taskId, chatsBase } = useChatsBase(taskBase); // 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。 @@ -163,8 +183,9 @@ export function TaskFxWorkspace({ taskBase }: { taskBase: string }) { return (
-
+
+
); @@ -192,7 +213,7 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) { ); } -/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板)。 */ +/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */ export function TaskFxChat({ taskBase }: { taskBase: string }) { const { agent_id } = useParams<{ agent_id: string }>(); return ( diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx index aaea6f2a..c0998568 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx @@ -1,15 +1,17 @@ /** * 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。 * - * 流程:拉目的树并展示 → 点「开始问答」: + * 展示:目的树拍平成表格(一行一条「目的→行为体→预期→因素→叙事」路径),列数随数据自适应, + * 字段/层级变动都不崩(见 flattenPurposeTree / normalizeNode 的容错)。可点「弹框查看」放大。 + * 流程:点「开始问答」: * - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart(); * success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。 * - 无数据 → 直接 onStart()。 * onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。 */ -import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Loader2, Maximize2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -24,28 +26,67 @@ import { import { deleteTaskPurpose, fetchTaskPurposeDetail, + flattenPurposeTree, type PurposeTreeNode, type TaskPurposeResult, } from "@/core/auth/task-purpose"; +import { cn } from "@/lib/utils"; + +/** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */ +function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) { + const rows = useMemo(() => flattenPurposeTree(tree), [tree]); + const headers = useMemo(() => { + const maxDepth = rows.reduce((m, r) => Math.max(m, r.length), 0); + return Array.from({ length: maxDepth }, (_, i) => { + const r = rows.find((row) => row[i]); + return r?.[i]?.label ?? `层级${i + 1}`; + }); + }, [rows]); + + if (rows.length === 0) { + return
暂无目的数据,可直接开始问答。
; + } -function PurposeNodes({ nodes, depth = 0 }: { nodes: PurposeTreeNode[]; depth?: number }) { return ( -
    - {nodes.map((node, i) => ( -
  • - - {node.label} - - {node.text || "(空)"} - {node.reason ? ( - 理由:{node.reason} - ) : null} - {node.children.length > 0 ? ( - - ) : null} -
  • - ))} -
+
+ + + + {headers.map((h, i) => ( + + ))} + + + + {rows.map((row, ri) => ( + + {headers.map((_, ci) => { + const cell = row[ci]; + return ( + + ); + })} + + ))} + +
+ {h} +
+ {cell ? ( + <> + {cell.text || "(空)"} + {cell.reason ? ( + 理由:{cell.reason} + ) : null} + + ) : ( + — + )} +
+
); } @@ -61,6 +102,7 @@ export function TaskPurposePanel({ const [result, setResult] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [working, setWorking] = useState(false); + const [zoomOpen, setZoomOpen] = useState(false); useEffect(() => { let cancelled = false; @@ -81,11 +123,8 @@ export function TaskPurposePanel({ }, [taskId]); const handleStart = () => { - if (result?.hasData) { - setConfirmOpen(true); // 有数据 → 先确认覆盖 - } else { - onStart(); // 无数据 → 直接开始 - } + if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖 + else onStart(); // 无数据 → 直接开始 }; const handleConfirmOverwrite = async () => { @@ -107,27 +146,43 @@ export function TaskPurposePanel({ } }; + const hasTree = !!result && result.tree.length > 0; + return (
任务目的(任务 {taskId}) - +
+ {hasTree && ( + + )} + +
{loading ? (
正在查询任务目的…
- ) : result && result.tree.length > 0 ? ( -
- -
) : ( -
暂无目的数据,可直接开始问答。
+ )} + {/* 弹框查看:放大显示完整表格 */} + + + + 任务目的(任务 {taskId}) + + + + + + {/* 覆盖确认 */} !working && setConfirmOpen(o)}> From 392394b4c9e886963a1bb15b9f50c39fe07b9aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 17 Jun 2026 17:19:29 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(db):=20=E4=BF=AE=E5=A4=8D=20alembic=20?= =?UTF-8?q?=E5=A4=9A=20head=20=E5=AF=BC=E8=87=B4=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移图存在两处问题导致 `alembic upgrade head`(网关启动时执行)报 "Multiple head revisions are present": 1. 20260612_01 分叉出 20260615_01 与 20260616_03 两个 head 未合并; 2. 20260611_01、20260616_02 各被两个文件重复占用,污染 DAG 产生幽灵 head。 处理: - roundtable_draft_task_id 20260611_01 -> 20260611_02(接在 square_id 之后), 20260612_01 父节点改指 20260611_02; - skill_favorites 20260616_02 -> 20260616_04(接在 knowledge_folders 之后), 20260616_03 父节点改指 20260616_04; - 新增 no-op 合并迁移 20260617_01 合并 (20260615_01, 20260616_03)。 保留 20260611_01 / 20260616_02 原 id 不变,仅重命名重复副本,已 stamp 的库不受影响; 所有迁移均有 _has_table/_has_column 守卫,全链路在已建表的库上重跑为安全 no-op。 `alembic heads` 现仅余单一 head 20260617_01,无重复告警。 Co-Authored-By: Claude Opus 4.8 (1M context) --- offline-backend-20260512/backend/CLAUDE.md | 2 +- ...> 20260611_02_roundtable_draft_task_id.py} | 14 ++++++--- ...2_01_roundtable_jobs_orchestration_plan.py | 4 +-- .../versions/20260616_03_skill_always_on.py | 4 +-- ...ites.py => 20260616_04_skill_favorites.py} | 14 ++++++--- ...ge_chain_seat_and_skill_always_on_heads.py | 30 +++++++++++++++++++ 6 files changed, 55 insertions(+), 13 deletions(-) rename offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/{20260611_01_roundtable_draft_task_id.py => 20260611_02_roundtable_draft_task_id.py} (77%) rename offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/{20260616_02_skill_favorites.py => 20260616_04_skill_favorites.py} (81%) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260617_01_merge_chain_seat_and_skill_always_on_heads.py diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index d75ca744..712c7339 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -368,7 +368,7 @@ shown to users ARE the **recommended questions** (`recommended_questions` table) types into the existing "我的" tables, idempotently: - agents → `agent_favorites` rows with `origin='position'` - **skills → `skill_favorites` rows with `origin='position'`** (table - `SkillFavoriteRow`, migration `20260616_02`, auto-created by `create_all`) + `SkillFavoriteRow`, migration `20260616_04`, auto-created by `create_all`) - scheduled tasks → `scheduled_task_subscriptions` rows with `origin='position'` Skills behave **exactly like agents**: a position only *adds* its tagged skills diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_01_roundtable_draft_task_id.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_02_roundtable_draft_task_id.py similarity index 77% rename from offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_01_roundtable_draft_task_id.py rename to offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_02_roundtable_draft_task_id.py index 076a329b..c80317d5 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_01_roundtable_draft_task_id.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_02_roundtable_draft_task_id.py @@ -7,8 +7,14 @@ draft is keyed/shared by ``task_id`` and is no longer partitioned by ``user_id`` ``create_all`` adds the column for brand-new DBs; this revision patches DBs that already have the table. -Revision ID: 20260611_01 -Revises: 20260609_02 +Note: this migration originally shared the revision id ``20260611_01`` with +``20260611_01_agents_skills_square_id`` (both branched off ``20260609_02``), +which corrupted the alembic DAG ("present more than once" + phantom heads). It +was renumbered to ``20260611_02`` and re-chained after that revision so the +history is linear and single-headed. + +Revision ID: 20260611_02 +Revises: 20260611_01 Create Date: 2026-06-11 """ @@ -19,8 +25,8 @@ from alembic import op from sqlalchemy import inspect -revision = "20260611_01" -down_revision = "20260609_02" +revision = "20260611_02" +down_revision = "20260611_01" branch_labels = None depends_on = None diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260612_01_roundtable_jobs_orchestration_plan.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260612_01_roundtable_jobs_orchestration_plan.py index f7a2cf90..a9b700fd 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260612_01_roundtable_jobs_orchestration_plan.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260612_01_roundtable_jobs_orchestration_plan.py @@ -6,7 +6,7 @@ LONGTEXT(计划 JSON 可超 64 KB)。 Revision ID: 20260612_01 -Revises: 20260611_01 +Revises: 20260611_02 Create Date: 2026-06-12 """ @@ -19,7 +19,7 @@ from sqlalchemy.dialects.mysql import LONGTEXT revision = "20260612_01" -down_revision = "20260611_01" +down_revision = "20260611_02" branch_labels = None depends_on = None diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_03_skill_always_on.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_03_skill_always_on.py index 68d458bd..57c0b0df 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_03_skill_always_on.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_03_skill_always_on.py @@ -1,7 +1,7 @@ """Add always_on (常驻/免压缩) column to skills table. Revision ID: 20260616_03 -Revises: 20260616_02 +Revises: 20260616_04 Create Date: 2026-06-16 """ @@ -13,7 +13,7 @@ from sqlalchemy import inspect revision = "20260616_03" -down_revision = "20260616_02" +down_revision = "20260616_04" branch_labels = None depends_on = None diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_02_skill_favorites.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_04_skill_favorites.py similarity index 81% rename from offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_02_skill_favorites.py rename to offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_04_skill_favorites.py index 0a223a3b..e531a31e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_02_skill_favorites.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_04_skill_favorites.py @@ -10,8 +10,14 @@ startup (new tables are always created, even on existing DBs); this migration keeps the alembic history consistent per the repo convention (see backend/CLAUDE.md → Schema Migrations). -Revision ID: 20260616_02 -Revises: 20260616_01 +Note: this migration originally shared the revision id ``20260616_02`` with +``20260616_02_knowledge_folders_templates`` (both branched off ``20260616_01``), +which corrupted the alembic DAG ("present more than once" + phantom heads). It +was renumbered to ``20260616_04`` and re-chained after that revision so the +history is linear and single-headed. + +Revision ID: 20260616_04 +Revises: 20260616_02 Create Date: 2026-06-16 """ @@ -22,8 +28,8 @@ from alembic import op from sqlalchemy import inspect -revision = "20260616_02" -down_revision = "20260616_01" +revision = "20260616_04" +down_revision = "20260616_02" branch_labels = None depends_on = None diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260617_01_merge_chain_seat_and_skill_always_on_heads.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260617_01_merge_chain_seat_and_skill_always_on_heads.py new file mode 100644 index 00000000..5eb84731 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260617_01_merge_chain_seat_and_skill_always_on_heads.py @@ -0,0 +1,30 @@ +"""Merge the chain-seat-mode and skill-always-on migration heads. + +Two lineages both descend from ``20260612_01`` (roundtable_jobs orchestration +plan): ``20260615_01`` (chain seat mode) branched directly off it, while the +``20260616_*`` line (positions → knowledge folders/templates → skill_favorites → +skill always_on) is the other branch. That left the graph with two heads +(``20260615_01`` and ``20260616_03``) so ``alembic upgrade head`` failed with +"Multiple head revisions are present". This no-op merge revision joins both +lineages so ``upgrade head`` resolves to a single head again. + +Revision ID: 20260617_01 +Revises: 20260615_01, 20260616_03 +Create Date: 2026-06-17 +""" + +from __future__ import annotations + + +revision = "20260617_01" +down_revision = ("20260615_01", "20260616_03") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass