From acc29c8121a27e9ca55e4447e2cd1da3681f64fd Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 24 Jun 2026 21:20:12 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix(middlewares):=20=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=8E=A8=E7=90=86=E4=B8=8D=E5=86=8D=E6=B3=84?= =?UTF-8?q?=E6=BC=8F=E5=88=B0=20UI=20=E6=B5=81=20+=20open/ephemeral=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=81=BF=E5=85=8D=E6=97=A0=E8=B0=93=20DB=20?= =?UTF-8?q?=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - title: 标题模型用隔离 config(不继承父级 streaming callbacks),force_disable_thinking, 块清洗,避免推理 token 进入实时流/运行日志 - uploads: 无文件的普通对话轮次直接跳过,不再解析线程归属(避免命中 DB) - thread_data: ephemeral 会话用当前登录用户解析数据目录 - thread_paths: open-/__skill-review__ 等合成线程视为 local-only,直接用当前用户 - frontend: 新增 build:test 构建脚本 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/package.json | 1 + .../middlewares/thread_data_middleware.py | 13 +++-- .../agents/middlewares/title_middleware.py | 54 +++++++++++-------- .../agents/middlewares/uploads_middleware.py | 7 +++ .../harness/deerflow/runtime/thread_paths.py | 10 +++- 5 files changed, 57 insertions(+), 28 deletions(-) diff --git a/frontend-web/package.json b/frontend-web/package.json index 1b854aca..5ec6dacc 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build", "build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit", + "build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test", "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "preview": "vite preview", "typecheck": "tsc --noEmit" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py index 9da88c66..3bf01bf1 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py @@ -11,6 +11,7 @@ from langgraph.runtime import Runtime from deerflow.agents.thread_state import ThreadDataState from deerflow.config.paths import Paths, get_paths from deerflow.runtime.thread_paths import resolve_path_user_id +from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) @@ -81,10 +82,15 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]): @override def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None: context = runtime.context or {} + configurable = {} + try: + config = get_config() + configurable = config.get("configurable", {}) or {} + except RuntimeError: + pass thread_id = context.get("thread_id") if thread_id is None: - config = get_config() - thread_id = config.get("configurable", {}).get("thread_id") + thread_id = configurable.get("thread_id") if thread_id is None: raise ValueError("Thread ID is required in runtime context or config.configurable") @@ -93,7 +99,8 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]): # thread creator so every account that shares the conversation writes to # the same physical user-data directory. Personal threads resolve to the # current login unchanged. - user_id = resolve_path_user_id(thread_id) + is_ephemeral = bool(context.get("ephemeral") or configurable.get("ephemeral")) + user_id = get_effective_user_id() if is_ephemeral else resolve_path_user_id(thread_id) if self._lazy_init: # Lazy initialization: only compute paths, don't create directories diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index f45d4b0c..63cbe592 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, NotRequired, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware -from langgraph.config import get_config from langgraph.runtime import Runtime from deerflow.config.title_config import get_title_config @@ -139,13 +138,29 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return text.strip() def _parse_title(self, content: object) -> str: - """Normalize model output into a clean title string.""" + """Normalize model output into a clean title string (strips blocks).""" config = self._get_title_config() - title_content = self._normalize_content(content) - title_content = self._strip_think_tags(title_content) + title_content = self._strip_think_tags(self._normalize_content(content)) title = title_content.strip().strip('"').strip("'") return title[: config.max_chars] if len(title) > config.max_chars else title + def _isolated_config(self) -> dict[str, Any]: + """Build a RunnableConfig that does NOT inherit the parent's streaming callbacks. + + The leak that pushed title-generation reasoning into the live UI stream came + from inheriting the parent RunnableConfig (and with it the SSE/streaming + callbacks) for the title model call. Here we deliberately start from an empty + config with ``callbacks: []`` so the title model's tokens — including any + ```` reasoning from a reasoning-only deployment — never reach the + frontend stream or the run journal as conversation output. The stored title + is additionally cleaned by ``_parse_title``/``_strip_think_tags``. + """ + return { + "run_name": "title_agent", + "tags": ["middleware:title"], + "callbacks": [], + } + def _fallback_title(self, user_msg: str) -> str: config = self._get_title_config() fallback_chars = min(config.max_chars, 50) @@ -153,21 +168,6 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return user_msg[:fallback_chars].rstrip() + "..." return user_msg if user_msg else "New Conversation" - def _get_runnable_config(self) -> dict[str, Any]: - """Inherit the parent RunnableConfig and add middleware tag. - - This ensures RunJournal identifies LLM calls from this middleware - as ``middleware:title`` instead of ``lead_agent``. - """ - try: - parent = get_config() - except Exception: - parent = {} - config = {**parent} - config["run_name"] = "title_agent" - config["tags"] = [*(config.get("tags") or []), "middleware:title"] - return config - def _provisional_title_result(self, state: TitleMiddlewareState) -> dict | None: """Set an instant placeholder title from the first user message (no LLM).""" if not self._should_set_provisional_title(state): @@ -221,7 +221,15 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): logger.debug("Could not override extra_body to disable thinking on title model", exc_info=True) async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None: - """Generate a title asynchronously and fall back locally on failure.""" + """Polish the title with an LLM, without leaking its reasoning to the UI. + + The title model is invoked with an *isolated* config (``_isolated_config``) + that does not inherit the parent's streaming callbacks, so even a + reasoning-only deployment's ```` tokens never reach the live UI + stream or the run journal. Thinking is also force-disabled on the model, + and the final stored title is stripped of any residual ```` blocks. + On any failure we fall back to the local question-derived title. + """ if not self._should_generate_title(state): return None @@ -229,7 +237,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): prompt, user_msg = self._build_title_prompt(state) try: - model_kwargs = {"thinking_enabled": False} + model_kwargs: dict[str, Any] = {"thinking_enabled": False, "force_disable_thinking": True} if self._app_config is not None: model_kwargs["app_config"] = self._app_config if config.model_name: @@ -237,12 +245,12 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): else: model = create_chat_model(**model_kwargs) self._force_disable_thinking(model) - response = await model.ainvoke(prompt, config=self._get_runnable_config()) + response = await model.ainvoke(prompt, config=self._isolated_config()) title = self._parse_title(response.content) if title: return {"title": title, "title_provisional": False} except Exception: - logger.debug("Failed to generate async title; falling back to local title", exc_info=True) + logger.debug("Failed to generate async title via LLM; falling back to local title", exc_info=True) return {"title": self._fallback_title(user_msg), "title_provisional": False} @override diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py index 37c937c2..777eb0ea 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py @@ -285,6 +285,13 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): if not isinstance(last_message, HumanMessage): return None + # Only turns with fresh upload metadata need the uploads directory. + # Avoid resolving the thread owner (which may hit the DB) for ordinary + # no-file chat turns, including ephemeral open-chat requests. + kwargs_files = (last_message.additional_kwargs or {}).get("files") + if not isinstance(kwargs_files, list) or not kwargs_files: + return None + # Resolve uploads directory for existence checks thread_id = (runtime.context or {}).get("thread_id") if thread_id is None: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py index 5dbc8565..2192395a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py @@ -42,6 +42,12 @@ logger = logging.getLogger(__name__) _owner_cache: dict[str, str] = {} _owner_cache_lock = threading.Lock() _OWNER_CACHE_MAX = 4096 +_LOCAL_ONLY_THREAD_PREFIXES = ("open-", "__skill-review__") + + +def _is_local_only_thread(thread_id: str | None) -> bool: + """Return True for synthetic threads that are never persisted in threads_meta.""" + return bool(thread_id) and str(thread_id).startswith(_LOCAL_ONLY_THREAD_PREFIXES) def _cache_get(thread_id: str) -> str | None: @@ -112,7 +118,7 @@ def resolve_path_user_id(thread_id: str | None) -> str: share the conversation read/write the same physical directory. For every other case it is the current login (``get_effective_user_id``). """ - if not thread_id: + if not thread_id or _is_local_only_thread(thread_id): return get_effective_user_id() cached = _cache_get(thread_id) if cached is not None: @@ -132,7 +138,7 @@ def resolve_path_user_id(thread_id: str | None) -> str: async def aresolve_path_user_id(thread_id: str | None) -> str: """Async variant of :func:`resolve_path_user_id` for FastAPI handlers.""" - if not thread_id: + if not thread_id or _is_local_only_thread(thread_id): return get_effective_user_id() cached = _cache_get(thread_id) if cached is not None: From e3e93712b61af9914b9ba0586e6d9305b7bb7417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 24 Jun 2026 22:33:09 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(task-deeplink):=20xdfx=20=E5=BC=80?= =?UTF-8?q?=E5=9C=BA=E4=BF=A1=E6=81=AF=E8=BF=BD=E5=8A=A0=E8=A1=8C=E5=8A=A8?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E4=B8=8E=E8=A1=8C=E5=8A=A8=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xdfx(行动分析)深链开场信息原先只附行动id(action_id),现额外 拼上行动标题(actionName)与行动内容(narration)。优先读登录时 缓存的行动详情(readActionDetail),无缓存再 fetchActionDetail,避免 重复请求。buildOpeningText 第三参数由 actionId 改为 ActionOpeningInfo。 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/core/auth/task-deeplink.ts | 28 +++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 0208f579..0ca46a84 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -233,16 +233,28 @@ export interface TaskDeeplinkTarget { state?: Record; } +/** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id。 */ +interface ActionOpeningInfo { + /** 行动id(action_id)。 */ + actionId?: string; + /** 行动标题(行动名称 actionName)。 */ + actionName?: string; + /** 行动内容(行动叙事 narration)。 */ + narration?: string; +} + /** * 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。 - * xdfx(行动分析)额外附上「行动id(action_id)」——传入 actionId 时追加该行。 + * xdfx(行动分析)额外附上「行动标题 / 行动内容 / 行动id(action_id)」——传入 action 时追加这几行。 */ -function buildOpeningText(detail: CopTaskDetail, taskId: string, actionId?: string): string { +function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: ActionOpeningInfo): string { return [ detail.taskName ? `任务名称:${detail.taskName}` : "", detail.taskContent ? `任务内容:${detail.taskContent}` : "", taskId ? `任务id(task_id)为${taskId}` : "", - actionId ? `行动id(action_id)为${actionId}` : "", + action?.actionName ? `行动标题:${action.actionName}` : "", + action?.narration ? `行动内容:${action.narration}` : "", + action?.actionId ? `行动id(action_id)为${action.actionId}` : "", ] .filter(Boolean) .join("\n"); @@ -316,13 +328,19 @@ export async function resolveTaskDeeplinkTarget( // 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定 // 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表, // 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。 - // xdfx(行动分析):聊天记录按「行动id」划分(partitionByAction),且开场信息追加 action_id。 + // xdfx(行动分析):聊天记录按「行动id」划分(partitionByAction),且开场信息追加 + // 行动标题 / 行动内容 / action_id(行动详情登录解析任务id 时已缓存,优先读缓存避免重复请求)。 const isXdfx = parsed.businessCode === XDFX_BUSINESS_CODE; const actionId = rawId || taskId; + let actionInfo: ActionOpeningInfo | undefined; + if (isXdfx) { + const ad = rawId ? (readActionDetail() ?? (await fetchActionDetail(rawId))) : null; + actionInfo = { actionId, actionName: ad?.actionName, narration: ad?.narration }; + } persistTaskContext({ taskId, agentId, - openingText: buildOpeningText(detail, taskId, isXdfx ? actionId : undefined), + openingText: buildOpeningText(detail, taskId, actionInfo), actionId, partitionByAction: isXdfx, }); From 6989dd45f1753cf30482c3d73622bd013b29c1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 24 Jun 2026 22:57:21 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(chat):=20=E9=97=AE=E7=AD=94=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=E6=97=B6=E9=97=B4=E6=88=B3=E4=B8=8D=E5=86=8D=E6=BC=82?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E5=BD=93=E5=89=8D=E7=94=B5=E8=84=91=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 回答无自身时间戳,原先在首次渲染时用 new Date() 兜底,导致 未在原会话完成、或从历史重新加载的回答显示加载时刻而非真实时间。 改为让 AI 回答继承同轮提问的可靠 client_ts(turnTime),并停止给 AI 消息打 now 戳;显示层优先用提问时间,可自愈历史错误时间。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/messages/message-list-item.tsx | 19 ++++++++- .../workspace/messages/message-list.tsx | 40 ++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 44721c07..6ddf0f4a 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -138,6 +138,7 @@ export function MessageListItem({ extraRehypePlugins, onEditResubmit, onMessageDeleted, + turnTime, }: { className?: string; fitRowToContent?: boolean; @@ -154,6 +155,13 @@ export function MessageListItem({ extraRehypePlugins?: ReturnType; onEditResubmit?: (text: string) => void; onMessageDeleted?: (deletedIds: string[]) => void; + /** + * Durable ask time of the turn this message belongs to (the preceding + * question's `client_ts`). Used to time-stamp the AI answer, which carries no + * timestamp of its own — without it the answer would fall back to the current + * wall clock at render time and appear to "drift" to now on every reload. + */ + turnTime?: string; }) { const isHuman = message.type === 'human'; const messageMarkdown = @@ -182,6 +190,7 @@ export function MessageListItem({ extraRehypePlugins={extraRehypePlugins} onEditResubmit={onEditResubmit} onMessageDeleted={onMessageDeleted} + turnTime={turnTime} /> {/* Human messages carry their own inline copy/edit toolbar inside @@ -390,6 +399,7 @@ function MessageContent_({ extraRehypePlugins, onEditResubmit, onMessageDeleted, + turnTime, }: { className?: string; message: Message; @@ -400,6 +410,7 @@ function MessageContent_({ extraRehypePlugins?: ReturnType; onEditResubmit?: (text: string) => void; onMessageDeleted?: (deletedIds: string[]) => void; + turnTime?: string; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); @@ -456,9 +467,15 @@ function MessageContent_({ [threadId, resolveArtifact], ); + // Question carries its own `client_ts` (round-trips through the checkpoint). + // The AI answer has no timestamp of its own, so it inherits the turn's ask + // time (its question's `client_ts`, threaded down as `turnTime`). This is + // durable and turn-accurate, and — unlike the old per-id `now` fallback — + // never drifts to the current clock when an unfinished answer is reloaded. + const ownAskTime = message.id ? askTimes[message.id] : undefined; const messageTime = formatAskedTime( message.additional_kwargs?.client_ts ?? - (message.id ? askTimes[message.id] : undefined), + (isHuman ? ownAskTime : (turnTime ?? ownAskTime)), ); const rawContent = extractContentFromMessage(message); diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 95c5668c..aa833058 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { rememberAskTimes } from "@/core/chat-timestamps"; +import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, @@ -167,15 +167,24 @@ export function MessageList({ [allMessages, hiddenMessageIds], ); - // Stamp a wall-clock time onto every question/answer the first time it is - // seen, in a single batched commit. Questions sent this session already carry - // an accurate time (see useThreadStream); this also covers assistant answers - // and messages loaded from history. First-write-wins keeps the earliest time. + const askTimes = useChatAskTimes(); + + // Stamp a wall-clock time onto every *question* the first time it is seen, in + // a single batched commit. Questions sent this session already carry an + // accurate `client_ts` (see useThreadStream) and are skipped; this only backs + // up legacy questions loaded from history that predate `client_ts`. + // First-write-wins keeps the earliest time. + // + // AI answers are deliberately NOT stamped here: they carry no timestamp of + // their own, so a `now` stamp would be the *load* time, not the answer time — + // and for an unfinished/interrupted turn that re-stamps to the current clock + // on every reload. Instead the answer inherits its question's durable time + // via `turnTimes` below. useEffect(() => { const now = new Date().toISOString(); const entries: Record = {}; for (const message of messages) { - if (message.type !== "human" && message.type !== "ai") continue; + if (message.type !== "human") continue; if (!message.id || message.id.startsWith("opt-")) continue; if (typeof message.additional_kwargs?.client_ts === "string") continue; entries[message.id] = now; @@ -183,6 +192,24 @@ export function MessageList({ if (Object.keys(entries).length > 0) rememberAskTimes(entries); }, [messages]); + // Durable ask time per message id, used to time-stamp the AI answer (which has + // no timestamp of its own) with the time of the question that triggered it. + const turnTimes = useMemo(() => { + const map = new Map(); + let lastQuestionTime: string | undefined; + for (const message of messages) { + if (message.type === "human") { + const ts = message.additional_kwargs?.client_ts; + const fromAskTimes = message.id ? askTimes[message.id] : undefined; + lastQuestionTime = + typeof ts === "string" && ts ? ts : fromAskTimes; + } else if (message.type === "ai" && message.id && lastQuestionTime) { + map.set(message.id, lastQuestionTime); + } + } + return map; + }, [messages, askTimes]); + const [settings] = useLocalSettings(); const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); @@ -358,6 +385,7 @@ export function MessageList({ onMessageDeleted={ allowDelete ? handleMessageDeleted : undefined } + turnTime={msg.id ? turnTimes.get(msg.id) : undefined} /> ); }); From f8b40353efd9b9fcfcde0ea1b7d84ad3970e9815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 25 Jun 2026 14:52:07 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(task-buttons):=20=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E5=8F=AF=E8=BF=BD=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=80=81=20token+username=20+=20token=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E6=B5=8B=E8=AF=95=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rwfx/3qfx/xdfx 任务工作区的可配置跳转按钮(按钮管理)新增「追加登录态」开关: url 类型按钮可在外链后拼 ?token=&username=<登录名>(参数名可配,默认 token/username),供跳转目标外部系统识别当前登录用户。 - 仅 token 登录(login/token)生效: token1 = ?authToken=,username = 后端用 token 调 getTokenInfo 解析出的原始登录名(非邮箱前缀/非解 JWT)。二者登录时 经 persistLoginCredentials 存入 login.token1/login.username(scoped-storage 自动隔离嵌入/独立会话);非 token 登录读不到 token1 则跳过追加。 - 后端 UsernameLoginResponse 回传 username(原始名,规避邮箱归一化有损); token_login 加 mock_tokens 离线测试映射(123ewq->admin),命中即跳过外网 getTokenInfo,便于本地全链路联调。 - task_buttons 加列 append_auth/auth_token_param/auth_name_param (migration 20260625_01 + 幂等 SQL)。 Tests: test_task_buttons.py, test_auth_login_modes.py(mock token/username 回传)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- frontend-web/src/core/auth/index.ts | 6 ++ .../src/core/auth/login-credentials.ts | 61 +++++++++++++++++++ frontend-web/src/core/auth/task-buttons.ts | 46 +++++++++++++- frontend-web/src/pages/LoginPage.tsx | 4 ++ frontend-web/src/pages/TaskButtonsPage.tsx | 34 +++++++++++ .../strategy-components/api/task-buttons.ts | 9 +++ offline-backend-20260512/backend/CLAUDE.md | 4 +- .../backend/app/gateway/routers/auth.py | 22 +++++++ .../app/gateway/routers/task_buttons.py | 5 ++ offline-backend-20260512/backend/config.yaml | 7 ++- .../harness/deerflow/config/login_config.py | 10 +++ .../20260625_01_task_buttons_auth_params.py | 61 +++++++++++++++++++ .../persistence/task_buttons/memory.py | 3 + .../persistence/task_buttons/model.py | 6 ++ .../deerflow/persistence/task_buttons/sql.py | 6 ++ .../scripts/sql/column_additions_mysql.sql | 12 +++- offline-backend-20260512/config.yaml | 6 ++ 18 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 frontend-web/src/core/auth/login-credentials.ts create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_01_task_buttons_auth_params.py diff --git a/CLAUDE.md b/CLAUDE.md index db045e54..9c3d4419 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 -- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. +- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`appendAuth` (追加登录态)**: a `url` button can additionally append `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` diff --git a/frontend-web/src/core/auth/index.ts b/frontend-web/src/core/auth/index.ts index 23659489..deca84f7 100644 --- a/frontend-web/src/core/auth/index.ts +++ b/frontend-web/src/core/auth/index.ts @@ -7,6 +7,12 @@ export interface LoginByUsernameResponse { system_role: string; needs_setup: boolean; created: boolean; + /** + * 本次登录解析出的原始登录用户名(token 登录=getTokenInfo 解析值;用户名登录=所传用户名)。 + * 用于跳转外链时拼 ``?username=``——后端落库的邮箱前缀是归一化(小写/替换)后的,有损,故以此为准。 + * 旧后端可能不返回。 + */ + username?: string; } import { diff --git a/frontend-web/src/core/auth/login-credentials.ts b/frontend-web/src/core/auth/login-credentials.ts new file mode 100644 index 00000000..69495c95 --- /dev/null +++ b/frontend-web/src/core/auth/login-credentials.ts @@ -0,0 +1,61 @@ +/** + * 任务工作区跳转外链时要拼接的「登录态凭据」:token1 + 登录用户名 的存取。 + * + * - **token1** = token 登录用的上游 token(登录 URL 的 ``?authToken=``,即走 + * ``/api/v1/auth/login/token`` 这条 token 换登录路径用的 token)。 + * - **username** = 后端 token 登录时用该 token 调 ``getTokenInfo`` 解析出的**登录用户名** + * (见 ``auth.py`` ``/login/token`` → ``UsernameLoginResponse.username``)。**不是**邮箱 + * 前缀、也**不是**解 JWT 得来——后端把它落库时会 ``.lower()`` + 归一化,邮箱前缀是有损的, + * 所以以后端回传的原始值为准。 + * + * 二者均经 [[scoped-storage]] 的 ``authStorage*`` 存取(键用 ``login.`` 前缀,属其受保护 + * 鉴权键族):嵌入(iframe)/独立会话态自动落 sessionStorage 隔离、正常态落 localStorage, + * 并随「开启独立会话」一起被 ``migrateAuthToSession`` 迁移。 + * + * **门控**:仅 token 登录(``authToken`` 存在)才写入。非 token 登录不写 token1 → 跳转按钮 + * 的「追加登录态」开关此时读不到 token,自然不追加(符合「必须用 login/token 登录才能带 + * token 跳转」)。 + */ + +import { + authStorageGet, + authStorageRemove, + authStorageSet, +} from "@/core/auth/scoped-storage"; +import { getAccountDisplayName, type LoginByUsernameResponse } from "@/core/auth"; + +const LOGIN_TOKEN_KEY = "login.token1"; +const LOGIN_USERNAME_KEY = "login.username"; + +/** + * 持久化 token 登录的 token1 + 用户名(token 登录成功后调用)。 + * ``authToken`` 为空则整次忽略(非 token 登录不留痕)。 + */ +export function persistLoginCredentials( + authToken: string | null | undefined, + auth: LoginByUsernameResponse | null, +): void { + const token = authToken?.trim(); + if (!token) return; + authStorageSet(LOGIN_TOKEN_KEY, token); + // 优先用后端回传的原始登录名(getTokenInfo 解析值);旧后端无该字段时退回账号显示名 + // (邮箱 @ 前缀)兜底,保证总有个值可用。 + const username = auth?.username?.trim() || getAccountDisplayName(auth); + if (username) authStorageSet(LOGIN_USERNAME_KEY, username); +} + +/** 读 token1(无则 ``null`` → 跳转不追加登录态)。 */ +export function readLoginToken(): string | null { + return authStorageGet(LOGIN_TOKEN_KEY); +} + +/** 读登录用户名(无则 ``null``)。 */ +export function readLoginUsername(): string | null { + return authStorageGet(LOGIN_USERNAME_KEY); +} + +/** 清除登录态凭据(登出时调用)。 */ +export function clearLoginCredentials(): void { + authStorageRemove(LOGIN_TOKEN_KEY); + authStorageRemove(LOGIN_USERNAME_KEY); +} diff --git a/frontend-web/src/core/auth/task-buttons.ts b/frontend-web/src/core/auth/task-buttons.ts index 84be6bfe..4e03c71b 100644 --- a/frontend-web/src/core/auth/task-buttons.ts +++ b/frontend-web/src/core/auth/task-buttons.ts @@ -20,6 +20,10 @@ import { readCopTaskDetail, resolveTaskDeeplinkTarget, } from "@/core/auth/task-deeplink"; +import { + readLoginToken, + readLoginUsername, +} from "@/core/auth/login-credentials"; /** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */ export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent"; @@ -85,6 +89,16 @@ export interface TaskButton { idKind: TaskButtonIdKind; /** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */ openMode: TaskButtonOpenMode; + /** + * ``url`` 跳转是否追加登录态:token1(token 登录的上游 token)+ 登录用户名,供外链所在的 + * 外部系统识别当前登录用户。**仅 token 登录**(前端存有 token1,见 [[login-credentials]])时 + * 实际拼上;非 token 登录读不到 token1,自动跳过(按钮照常跳,只是不带这两个参数)。 + */ + appendAuth: boolean; + /** 追加 token 的查询参数名(空则用 ``token``)。 */ + authTokenParam: string; + /** 追加用户名的查询参数名(空则用 ``username``)。 */ + authNameParam: string; enabled: boolean; sortOrder: number; } @@ -120,6 +134,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idParam: "id", idKind: "task", openMode: "blank", + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder, }); @@ -134,6 +151,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idParam: "id", idKind: "task", openMode: "blank", + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder, }); @@ -149,6 +169,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idParam: "id", idKind: "task", openMode: "blank", + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: 0, }, @@ -167,6 +190,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idParam: "taskId", idKind: "task", openMode: "self", + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: 1, }, @@ -180,6 +206,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idParam: "id", idKind: "task", openMode: "blank", + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: 2, }, @@ -193,6 +222,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idParam: "id", idKind: "task", openMode: "blank", + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: 3, }, @@ -259,7 +291,19 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro // 行动分析(xdfx)可选追加行动id;否则用任务id。行动id 缺失时回退任务id。 const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId; const param = (btn.idParam || "id").trim() || "id"; - const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base; + let url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base; + // 追加登录态:仅 token 登录(存在 token1)才拼 token + 用户名,供外链所在的外部系统识别 + // 当前登录用户。非 token 登录读不到 token1 → 整段跳过(不影响跳转本身)。 + if (btn.appendAuth) { + const token1 = readLoginToken(); + if (token1) { + url = appendQueryParam(url, (btn.authTokenParam || "token").trim() || "token", token1); + const name = readLoginUsername(); + if (name) { + url = appendQueryParam(url, (btn.authNameParam || "username").trim() || "username", name); + } + } + } // 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页): // self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。 if (base.startsWith("/") && !base.startsWith("//")) { diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index d52b5c7e..f8aaea8d 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -33,6 +33,7 @@ import { } from '@/core/page-layout/sidebar-enabled'; import { fetchYLogConfig } from '@/core/log/y-log-config'; import { persistYLogToken } from '@/core/log/y-log-token'; +import { persistLoginCredentials } from '@/core/auth/login-credentials'; import { OptType, writeYLog } from '@/core/log/y-log'; const DEFAULT_USERNAME = 'guest'; @@ -167,6 +168,9 @@ export default function LoginPage() { // 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。 if (authToken) { persistYLogToken(authToken); + // 存下 token1 + 登录用户名(getTokenInfo 解析出的原始名,后端回传),供任务工作区 + // 跳转按钮的「追加登录态」拼到外链上(仅 token 登录才有这两个值)。 + persistLoginCredentials(authToken, auth); void fetchYLogConfig(); writeYLog({ optType: OptType.login, diff --git a/frontend-web/src/pages/TaskButtonsPage.tsx b/frontend-web/src/pages/TaskButtonsPage.tsx index 59d8373b..71312564 100644 --- a/frontend-web/src/pages/TaskButtonsPage.tsx +++ b/frontend-web/src/pages/TaskButtonsPage.tsx @@ -105,6 +105,9 @@ export default function TaskButtonsPage() { idParam: "id", idKind: "task", openMode: "blank", + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: maxSort + 1, }, @@ -319,6 +322,37 @@ export default function TaskButtonsPage() { 当前窗口 + + {r.appendAuth && ( +
+ & + update(r.id, { authTokenParam: e.target.value })} + placeholder="token" + className="h-8 w-24" + title="token 的查询参数名(默认 token)" + /> + =token1 & + update(r.id, { authNameParam: e.target.value })} + placeholder="username" + className="h-8 w-28" + title="用户名的查询参数名(默认 username)" + /> + =用户名 +
+ )} ) : ( setQuery(e.target.value)} + className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5" + /> + + + + {/* 用户列表 */} +
+
+ 用户 + 问答 + 放行 +
+ + {loadingUsers ? ( +
加载中…
+ ) : visibleUsers.length === 0 ? ( +
+ {users.length === 0 ? "暂无注册用户" : "没有匹配的用户"} +
+ ) : ( +
    + {visibleUsers.map((user) => { + const isUserAdmin = user.system_role === "admin"; + const allowed = user.approved || isUserAdmin; + return ( +
  • +
    +
    + {user.email} + {isUserAdmin ? ( + + + 管理员 + + ) : allowed ? ( + + 已放行 + + ) : ( + + 待审批 + + )} +
    +
    {user.id}
    +
    +
    {user.qa_count}
    +
    + handleToggleApproval(user.id, v)} + /> +
    +
  • + ); + })} +
+ )} +
+ + + ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 84714ed1..1aa6498a 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -39,6 +39,7 @@ import LightAppIframeView from "./LightAppIframeView"; import LightAppListPage from "./LightAppListPage"; import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; +import LoginWhitelistPage from "./LoginWhitelistPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; import SkillAddressesPage from "./SkillAddressesPage"; import TaskButtonsPage from "./TaskButtonsPage"; @@ -395,6 +396,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> `${getBackendBaseURL()}/api/system-settings/login-whitelist`; + +async function readJson(res: Response, ctx: string): Promise { + if (!res.ok) { + const data = await res.json().catch(() => null as unknown); + const detail = + data && typeof data === "object" && "detail" in data + ? String((data as { detail: unknown }).detail) + : `${ctx} failed (${res.status})`; + throw new Error(detail); + } + return (await res.json()) as T; +} + +export async function getLoginWhitelistEnabled(): Promise { + const res = await apiFetch(BASE(), { method: "GET" }); + const data = await readJson<{ enabled: boolean }>(res, "load login whitelist"); + return Boolean(data.enabled); +} + +export async function setLoginWhitelistEnabled(enabled: boolean): Promise { + const res = await apiFetch(BASE(), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const data = await readJson<{ enabled: boolean }>(res, "save login whitelist"); + writeYLog({ + optType: enabled ? OptType.enabled : OptType.disabled, + optDetail: `CM助手-全量-白名单管理-${enabled ? "启用" : "停用"}登录白名单`, + url: "/api/system-settings/login-whitelist", + reqParam: { enabled }, + }); + return Boolean(data.enabled); +} + +export const LOGIN_WHITELIST_KEY = "login-whitelist-enabled"; + +export function useLoginWhitelistEnabled() { + return useQuery({ + queryKey: [LOGIN_WHITELIST_KEY], + queryFn: getLoginWhitelistEnabled, + refetchOnWindowFocus: false, + staleTime: 60_000, + }); +} + +export function useSetLoginWhitelistEnabled() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: setLoginWhitelistEnabled, + onSuccess: () => queryClient.invalidateQueries({ queryKey: [LOGIN_WHITELIST_KEY] }), + }); +} diff --git a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py index 74527c2e..30cb55d1 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py +++ b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py @@ -79,7 +79,7 @@ class LocalAuthProvider(AuthProvider): """ return await self._repo.find_users_by_email_local_part_like(substring, limit=limit) - async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User: + async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False, approved: bool = False) -> User: """Create a new local user. Args: @@ -87,6 +87,8 @@ class LocalAuthProvider(AuthProvider): password: Plain text password (will be hashed) system_role: Role to assign ("admin" or "user") needs_setup: If True, user must complete setup on first login + approved: Login-whitelist 放行标记. Regular self-registered users + default to ``False`` (待审批); admins are created ``True``. Returns: Created User instance @@ -97,6 +99,7 @@ class LocalAuthProvider(AuthProvider): password_hash=password_hash, system_role=system_role, needs_setup=needs_setup, + approved=approved, ) return await self._repo.create_user(user) diff --git a/offline-backend-20260512/backend/app/gateway/auth/models.py b/offline-backend-20260512/backend/app/gateway/auth/models.py index 5298a856..8484df23 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/models.py +++ b/offline-backend-20260512/backend/app/gateway/auth/models.py @@ -30,6 +30,7 @@ class User(BaseModel): # Auth lifecycle needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") + approved: bool = Field(default=False, description="登录白名单放行标记;仅当系统设置 login_whitelist.enabled 打开时拦截未放行用户。新普通用户默认待审批(False)。") class UserResponse(BaseModel): diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py index dde62309..0a37ddbe 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py +++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py @@ -46,6 +46,7 @@ class SQLiteUserRepository(UserRepository): oauth_id=row.oauth_id, needs_setup=row.needs_setup, token_version=row.token_version, + approved=row.approved, ) @staticmethod @@ -60,6 +61,7 @@ class SQLiteUserRepository(UserRepository): oauth_id=user.oauth_id, needs_setup=user.needs_setup, token_version=user.token_version, + approved=user.approved, ) # ── CRUD ────────────────────────────────────────────────────────── @@ -154,6 +156,7 @@ class SQLiteUserRepository(UserRepository): row.oauth_id = user.oauth_id row.needs_setup = user.needs_setup row.token_version = user.token_version + row.approved = user.approved await session.commit() return user diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index 33bda94d..d0c63079 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -12,6 +12,7 @@ from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, EmailStr, Field, field_validator from deerflow.config import get_app_config +from deerflow.config.system_settings import load_system_settings from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.skills.base import SkillStore @@ -255,7 +256,9 @@ async def _get_or_create_user_by_username(username: str): if user is None: try: role = "admin" if username == "admin" else "user" - user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False) + # admin 创建即放行;普通用户自助注册后默认「待审批」(approved=False), + # 需管理员在白名单管理页放行——但仅当登录白名单开关打开时才会被拦。 + user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False, approved=(role == "admin")) created = True logger.info("Auto-registered passwordless user: %s", email) except ValueError: @@ -276,6 +279,35 @@ async def _get_or_create_user_by_username(username: str): return user, created +def enforce_user_approved(user) -> None: + """登录白名单拦截:开关打开且用户未放行时拒绝登录(注册照常,只是不发 token)。 + + 规则(三条硬约束防锁死): + - 开关关(``login_whitelist.enabled`` 默认 False)→ 直接放行,人人可登录。 + - admin → 永远放行(避免管理员把自己关在门外)。 + - 其余:``approved`` 为 False(注册后待审批)→ 403 ``PENDING_APPROVAL``; + 前端据此提示「等待管理员开通」,且不写任何登录态。 + + 放在各登录端点拿到 user 之后、签发 JWT 之前调用——账号此时已自助注册完成, + 被拦只是拿不到 token,管理员可在白名单管理页看到该待审批账号并放行。 + """ + try: + enabled = load_system_settings().login_whitelist.enabled + except Exception: + # 读配置失败时绝不误伤(白名单是附加闸门,故障时回到「不拦截」)。 + logger.warning("读取登录白名单配置失败,本次登录不拦截", exc_info=True) + return + if not enabled: + return + if getattr(user, "system_role", None) == "admin": + return + if not getattr(user, "approved", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "PENDING_APPROVAL", "message": "您的账号正在等待管理员开通,请联系管理员"}, + ) + + async def _fetch_username_from_token(token: str) -> str: """Resolve an upstream access token to a username via the configured endpoint. @@ -507,6 +539,7 @@ async def login_local( ) _record_login_success(client_ip) + enforce_user_approved(user) token = create_access_token(str(user.id), token_version=user.token_version) _set_session_cookie(response, token, request) await _sync_user_position(request, user.id) @@ -552,6 +585,7 @@ async def login_by_username( user, created = await _get_or_create_user_by_username(body.username) + enforce_user_approved(user) token = create_access_token(str(user.id), token_version=user.token_version) _set_session_cookie(response, token, request) await _sync_user_position(request, user.id) @@ -604,6 +638,7 @@ async def login_by_token( user, created = await _get_or_create_user_by_username(username) + enforce_user_approved(user) access_token = create_access_token(str(user.id), token_version=user.token_version) _set_session_cookie(response, access_token, request) await _sync_user_position(request, user.id) @@ -767,7 +802,7 @@ async def initialize_admin(request: Request, response: Response, body: Initializ ) try: - user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False) + user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False, approved=True) except ValueError: # DB unique-constraint race: another concurrent request beat us. raise HTTPException( @@ -791,6 +826,7 @@ class UserListItem(BaseModel): skill_count: int agent_count: int qa_count: int = 0 # 问答次数: number of runs (one run = one question) + approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消) @router.get("/users", response_model=list[UserListItem]) @@ -823,11 +859,44 @@ async def list_all_users( skill_count=await skill_store.count_owned(uid), agent_count=await agent_store.count_owned(uid), qa_count=qa_counts.get(uid, 0), + approved=getattr(u, "approved", True), ) ) return result +class UserApprovalRequest(BaseModel): + approved: bool = Field(..., description="True=放行该用户登录;False=取消放行(下次登录被拦)") + + +@router.put("/users/{user_id}/approval", response_model=UserListItem) +async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Request) -> UserListItem: + """放行 / 取消放行某个注册用户(登录白名单)。Admin only。 + + 取消放行只拦该用户的下次登录(不动 ``token_version``,现有会话不强制下线)。 + """ + admin = await get_current_user_from_request(request) + if admin.system_role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + + provider = get_local_provider() + target = await provider.get_user(user_id) + if target is None: + raise HTTPException(status_code=404, detail="用户不存在") + + target.approved = bool(body.approved) + saved = await provider.update_user(target) + return UserListItem( + id=str(saved.id), + email=saved.email, + system_role=saved.system_role, + skill_count=0, + agent_count=0, + qa_count=0, + approved=saved.approved, + ) + + # ── OAuth Endpoints (Future/Placeholder) ───────────────────────────────── diff --git a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py index fc014c8d..1241b5cd 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py +++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py @@ -451,6 +451,36 @@ class AgentModuleConfigUpdateRequest(BaseModel): tag_ids: list[str] = Field(default_factory=list, description="Full replacement list of selected agent tag ids") +class LoginWhitelistResponse(BaseModel): + enabled: bool = Field(..., description="是否启用登录白名单(关=所有人可登录,开=仅已放行用户/admin 可登录)") + + +class LoginWhitelistUpdateRequest(BaseModel): + enabled: bool | None = None + + +@router.get("/login-whitelist", response_model=LoginWhitelistResponse) +async def get_login_whitelist(request: Request) -> LoginWhitelistResponse: + """Return the login-whitelist master switch. Admin only (它驱动谁能登录,不公开).""" + await _require_admin(request) + return LoginWhitelistResponse(enabled=load_system_settings().login_whitelist.enabled) + + +@router.put("/login-whitelist", response_model=LoginWhitelistResponse) +async def update_login_whitelist(body: LoginWhitelistUpdateRequest, request: Request) -> LoginWhitelistResponse: + """Toggle the login-whitelist master switch. Admin only.""" + await _require_admin(request) + + current = load_system_settings() + update: dict[str, object] = {} + fields = body.model_dump(exclude_unset=True) + if "enabled" in fields and fields["enabled"] is not None: + update["enabled"] = bool(fields["enabled"]) + updated = current.login_whitelist.model_copy(update=update) + saved = save_system_settings(current.model_copy(update={"login_whitelist": updated})) + return LoginWhitelistResponse(enabled=saved.login_whitelist.enabled) + + @router.get("/agent-modules/{key}", response_model=AgentModuleConfigResponse) async def get_agent_module_config(key: str) -> AgentModuleConfigResponse: """Return the agent tag ids configured for ``key`` (readable by any user — diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index e95183c1..052c4be4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -221,10 +221,23 @@ def set_module_tag_ids( return settings.model_copy(update={"modules": modules}) +class LoginWhitelistSettings(BaseModel): + """登录白名单总开关(管理员可在「白名单管理」页切换,即时生效、免重启)。 + + ``enabled`` 关(默认) → 忽略每个用户的 ``approved`` 放行标记,人人可登录; + 开 → 仅 ``approved=True`` 或 admin 可登录,其余用户注册后处于「待审批」, + 被管理员放行前一律 403。空/默认即关,故部署后行为零变化。 + """ + + enabled: bool = Field(default=False, description="是否启用登录白名单;关=所有人可登录,开=仅已放行用户(或 admin)可登录") + model_config = ConfigDict(extra="ignore") + + class SystemSettings(BaseModel): """Top-level system settings document.""" prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings) + login_whitelist: LoginWhitelistSettings = Field(default_factory=LoginWhitelistSettings) private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings) publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings) appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py new file mode 100644 index 00000000..989fc130 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py @@ -0,0 +1,52 @@ +"""Add users.approved (登录白名单放行标记). + +Backs the login-whitelist feature: when system setting ``login_whitelist.enabled`` +is on, only ``approved=True`` users (or admins) may log in; self-registered +regular users default to ``approved=False`` (待审批) until an admin approves them. + +Existing rows are backfilled to TRUE (``server_default="1"``) so flipping the +switch on for the first time never locks out users who already had accounts. + +Revision ID: 20260625_02 +Revises: 20260625_01 +Create Date: 2026-06-25 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260625_02" +down_revision = "20260625_01" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + # A brand-new DB gets the column from create_all; only patch existing tables. + if _has_table("users") and not _has_column("users", "approved"): + op.add_column( + "users", + # server_default="1" backfills existing rows to 已放行 (avoid lockout). + sa.Column("approved", sa.Boolean(), nullable=False, server_default="1"), + ) + + +def downgrade() -> None: + if _has_column("users", "approved"): + op.drop_column("users", "approved") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py index 2c065b65..ff90ee6e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py @@ -49,6 +49,13 @@ class UserRow(Base): needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) token_version: Mapped[int] = mapped_column(nullable=False, default=0) + # 登录白名单(放行)标记。仅当系统设置 ``login_whitelist.enabled`` 打开时生效: + # 关 → 忽略此列,人人可登录;开 → 仅 approved=True(或 admin)可登录,其余 + # 注册后处于「待审批」直到管理员放行。新注册的普通用户默认 False(待审批), + # admin 创建即 True;存量用户由迁移回填 True(避免首次打开开关时把老用户锁死)。 + # server_default=1 让迁移 ADD COLUMN 时把已存在行置为已放行。 + approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("1")) + # 岗位 (position) the user is assigned to (1:1). NULL = no explicit # position → the user inherits the ``is_default`` position's grants. Drives # the subscription sync engine (see ``deerflow.services.position_sync``). diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index 758dff98..75439e15 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -117,6 +117,13 @@ CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFA -- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。 CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL'); +-- ---- users.approved: 登录白名单放行标记,对应 migration 20260625_02 ----------- +-- 系统设置 login_whitelist.enabled 打开后,仅 approved=1(或 admin)可登录;普通用户 +-- 自助注册后默认 0(待审批),管理员在白名单管理页放行。DEFAULT 1 让存量用户(执行本 +-- 脚本补列时)一并置为已放行,避免首次打开开关把老用户锁死。 +-- ORM: UserRow.approved = Boolean, NOT NULL, server_default '1'。 +CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1'); + -- ---- 清理临时存储过程 -------------------------------------------------------- DROP PROCEDURE IF EXISTS _add_column_if_missing; From d35d121261863eae28853bf40922c125177b6b63 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 25 Jun 2026 17:37:47 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat(auth):=20=E7=99=BB=E5=BD=95=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E4=B9=9F=E6=8B=A6=E3=80=8C=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E9=97=AE=E7=AD=94=E3=80=8D(=E5=B7=B2=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=9A=84=20run=20=E6=8E=A5=E5=8F=A3=E6=8C=89=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=94=BE=E8=A1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开关开启时,未放行用户(非 admin)即便持旧 token,直接调发起问答的 run 接口 (/api/threads/{id}/runs[/stream|/wait]、/api/runs/{stream|wait})也返回 403, 不止拦登录。在 AuthMiddleware 解析出 user 后仅对问答 run 请求复用 enforce_user_approved;GET 列表/join/cancel/feedback 及普通浏览不受影响; 内部/渠道调用与无鉴权 /api/open/chat 不在此范围。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/app/gateway/auth_middleware.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/offline-backend-20260512/backend/app/gateway/auth_middleware.py b/offline-backend-20260512/backend/app/gateway/auth_middleware.py index e6c84b4d..9c039c50 100644 --- a/offline-backend-20260512/backend/app/gateway/auth_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/auth_middleware.py @@ -9,6 +9,7 @@ owner filtering works automatically via the sentinel pattern. Fine-grained permission checks remain in authz.py decorators. """ +import re from collections.abc import Callable from fastapi import HTTPException, Request, Response @@ -64,6 +65,20 @@ def _is_public(path: str) -> bool: return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES) +# 「发起问答」的 run 创建接口(会真正调用大模型): +# POST /api/threads/{id}/runs[/stream|/wait] +# POST /api/runs/{stream|wait} +# (含 nginx 未改写到的 /api/langgraph/... 前缀,双保险) +# 只匹配这些「提交问答」的 POST,不碰 GET 列表 / join / cancel / feedback 等, +# 这样登录白名单开启后,未放行用户(凭旧 token)无法再用接口问答,但普通浏览不受影响。 +_QA_RUN_RE = re.compile(r"^/api/(?:langgraph/)?(?:threads/[^/]+/)?runs(?:/stream|/wait)?$") + + +def _is_qa_run_request(method: str, path: str) -> bool: + """True for an authenticated「发起问答」run-creation request (see ``_QA_RUN_RE``).""" + return method.upper() == "POST" and bool(_QA_RUN_RE.match(path.rstrip("/"))) + + class AuthMiddleware(BaseHTTPMiddleware): """Strict auth gate: reject requests without a valid session. @@ -148,6 +163,18 @@ class AuthMiddleware(BaseHTTPMiddleware): # JWT-decode + DB-lookup pipeline a second time per request). request.state.user = user request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) + + # 登录白名单:开关开启时,未放行用户(非 admin)不仅被拦在登录外,连「接口 + # 问答」也封掉——已拿到 token 的未放行用户用旧会话直接调问答接口同样被拒。 + # 仅作用于「发起问答」的 run 接口;内部/渠道调用(internal_user)不受限。 + if internal_user is None and _is_qa_run_request(request.method, app_relative_path(request)): + from app.gateway.routers.auth import enforce_user_approved + + try: + enforce_user_approved(user) + except HTTPException as exc: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + token = set_current_user(user) try: return await call_next(request) From fe7c6cb8f77e152dd290d6a05e0b34087f765e66 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 25 Jun 2026 19:19:17 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat(open-3qfx):=20=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=8C=89=20taskId=20=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=203qfx=20=E7=AC=AC=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E9=97=AE=E7=AD=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/open/3qfx/ask(无需登录,/api/open/ 白名单 + CSRF 豁免): 只传一个 taskId,后端解析 3Q 业务配置的智能体 + 按 id 拉取 cop-task-detail 拼出与前端一致的开场白,新建带 metadata.{taskId,agent_id} 的真实会话并后台 启动持久化运行,立即返回 thread_id(随后在 3qfx 任务工作区对话列表观看)。 - start_run() 增加 keyword-only enforce_access(默认 True);开放接口传 False 绕过按当前(无)登录用户的智能体可见性校验 - task_deeplink 配置新增 cop_task_detail_url / _authorization / _test + mock - tests/test_open_3qfx.py(10 项,tests/ 被 .gitignore 故未入库) Co-Authored-By: Claude Opus 4.8 (1M context) --- offline-backend-20260512/backend/CLAUDE.md | 1 + .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/routers/open_3qfx.py | 186 ++++++++++++++++++ .../backend/app/gateway/services.py | 10 +- offline-backend-20260512/backend/config.yaml | 11 ++ .../deerflow/config/task_deeplink_config.py | 12 ++ 6 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 offline-backend-20260512/backend/app/gateway/routers/open_3qfx.py diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 5647fcc6..6bcc23d5 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -244,6 +244,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific | | **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id | | **Open Chat** (`/api/open/chat`) | **Unauthenticated** open Q&A entry for external systems (the `/api/open/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES` + CSRF-exempt in `csrf_middleware.should_check_csrf`). `POST /chat` `{message \| messages[], model_name?, agent_name?, agent_id?, thinking_enabled=false, strip_think=true, thread_id?, recursion_limit=100}` → resolves the agent by **Chinese display name** (`agent_name`, matched against each `.deer-flow/agents/*/config.yaml` `name` via `_resolve_agent_id`; explicit `agent_id` wins if given), builds the lead agent via `make_lead_agent` (so the chosen **agent's** SOUL/tools/skills all apply), `ainvoke`s it once (stateless — no checkpointer, no thread persistence; runs under the `"default"` user bucket), and returns **only the final** `{answer, model_name, agent_name, agent_id, thinking_enabled, thread_id}` (no intermediate tool-call/process). **`POST /chat/stream`** is the **SSE streaming variant** (same request body) for **long tasks** — the blocking `/chat` produces no downstream bytes for the whole run, so a front nginx `proxy_read_timeout` (default 60s) cuts it off with a **504**; the stream keeps the connection warm (a `: keepalive` SSE comment every `_HEARTBEAT_SECONDS=15`s via a background producer-task + queue, plus `event: delta` AI-text increments and `event: progress`), and ends with **`event: result`** carrying the same final `{answer, …}` JSON (or `event: error`). Clients only need to read `event: result`; intermediate events are ignorable. Both endpoints share `_build_run_context` (config/agent/thread). `GET /agents` lists `{name, id}` so callers know what `agent_name` to send. **Thinking off is reliable**: when `thinking_enabled=false` the route also sets `thinking_force_disabled=true` so the lead agent calls `create_chat_model(force_disable_thinking=True)`, which injects **three** OpenAI-compatible "thinking off" shapes into `extra_body` — top-level `enable_thinking=false` (**SiliconFlow / DashScope hybrid models** like `deepseek-ai/DeepSeek-V4-*` on `api.siliconflow.cn` — the knob the nested `chat_template_kwargs` shape does NOT reach), `chat_template_kwargs.enable_thinking=false` (vLLM/Qwen native), and `thinking.type="disabled"` (Zhipu GLM) — even for models that only declare `supports_thinking: true`. For the **web-UI path** (which sends `thinking_enabled=false` but NOT `force_disable`), SiliconFlow models need `when_thinking_disabled: {extra_body: {enable_thinking: false}}` in their `config.yaml` entry (added to `deepseek-ai/DeepSeek-V4-Flash`) for thinking-off to take effect. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. **Generated-file return (`include_files=true`, default on)**: an agent that produces its report as a file (writes to `/mnt/user-data/outputs/` + `present_files`) returns only a short summary in `answer`; to recover the real deliverable both endpoints now also return `files: [{name, virtual_path, encoding(text|base64), content}]` — `_collect_output_files` scans the run's `state['thread_data']['outputs_path']` (the open thread is stateless so artifacts can't be fetched by thread, but the endpoint runs server-side and the sandbox already wrote the files to disk), text decoded as utf-8 else base64, capped by `_MAX_OUTPUT_FILES=20` / `_MAX_OUTPUT_FILE_BYTES=5MB`. `/chat` puts `files` on the response; `/chat/stream` puts it in the `event: result` payload. A run with no answer **and** no files now 502s ("…文本答案或文件"). Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root, streaming by default); tests `tests/test_open_chat.py`. | +| **Open 3qfx Ask** (`/api/open/3qfx/ask`) | **Unauthenticated** open entry that, given just a **任务id**, directly kicks off the 3qfx(三情分析)「第一次问答」as a **real persisted conversation** (sibling of `/api/open/chat`; same `/api/open/` auth-whitelist + CSRF-exempt). `POST /ask` `{task_id, message?, model_name?, thinking_enabled=false}` → (1) resolves the `3Q` agent from `app.state.business_mapping_store.get_mapping("3Q")` (the **same** agent the frontend deep-link `goPath=3qfx` uses; 503 if unconfigured); (2) **fetches the task detail server-side** (consumer `cop-task-detail`) and builds the opening text **identical to the frontend** `buildOpeningText` 3qfx branch — `任务名称:…\n任务内容:…\n任务id(task_id)为{task_id}` (an explicit `message` overrides and **skips** the fetch); (3) creates a fresh thread tagged `metadata.{taskId, agent_id}` (the exact filter `useTaskThreads` uses, and `metadata.taskId` makes it task-deeplink-shared / creator-path-resolved) and **starts a background persisted run** via `start_run(..., enforce_access=False)` (writes the checkpointer, runs under the `"default"` bucket); (4) **returns immediately** `{thread_id, run_id, agent_id, status, opening_text}` — the caller just needs "started", then watches the run in the 3qfx 任务工作区 conversation list. `start_run` gained a keyword-only `enforce_access` (default `True`); the open entry passes `False` so the admin-configured agent isn't rejected by the per-(missing)-login visibility check. **Consumer fetch config** lives in `config.yaml → task_deeplink`: `cop_task_detail_url` (full URL, appends `?taskId=`), `cop_task_detail_authorization` (default `Bearer admin`), `cop_task_detail_test` + `mock.cop_task_detail` (offline fake when the upstream is unreachable — on in this deployment). Router `app/gateway/routers/open_3qfx.py`; tests `tests/test_open_3qfx.py`. | | **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. | | **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. | | **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta — incl. `chain_title`, the business-chain name used this session, cheaply regex-extracted from the in-memory `step2` blob in `_draft_to_meta`/`_extract_chain_title`; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. | diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index ad0f5dd9..4e0fe6ea 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -50,6 +50,7 @@ from app.gateway.routers import ( models, multi_agent, notifications, + open_3qfx, open_chat, parallel_agent_tasks, parallel_agents, @@ -1478,6 +1479,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式 app.include_router(open_chat.router) + # 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话) + app.include_router(open_3qfx.router) + # --- ?????????? /page/workspace/roundtable/planning?--- # ??? app/gateway/routers/{multi_agent,intent,recommend}.py? # ??? frontend-web/docs/multi-agent-backend-dev.md diff --git a/offline-backend-20260512/backend/app/gateway/routers/open_3qfx.py b/offline-backend-20260512/backend/app/gateway/routers/open_3qfx.py new file mode 100644 index 00000000..ede4d92b --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/open_3qfx.py @@ -0,0 +1,186 @@ +"""开放接口:按 taskId 直接发起 3qfx(三情分析)的「第一次问答」(无需登录)。 + +外部系统只需把一个 **任务id** 传进来,后端就会: + +1. 解析 ``3Q`` 业务配置的智能体(``business_mapping`` 里 ``business_code=="3Q"`` 那一行的 + ``agent_id``,与前端任务深链 ``goPath=3qfx`` 取的是同一个); +2. 按 taskId 拉取任务详情(consumer 的 ``cop-task-detail``,地址 / 鉴权头 / mock 都在 + ``config.yaml`` 的 ``task_deeplink`` 段),拼出与前端深链一致的开场白 + ``任务名称:…\n任务内容:…\n任务id(task_id)为…``; +3. **新建一条真实会话**(thread 打 ``metadata.{taskId, agent_id}`` 标签,与前端 + ``taskThreadMetadata`` / ``useTaskThreads`` 的过滤口径一致),以开场白作为第一条用户消息 + **后台启动一次持久化运行**(写 checkpointer,不阻塞); +4. **立即返回** ``{thread_id, run_id, agent_id, status}`` —— 调用方拿到「调用成功」即可, + 随后在 3qfx 任务工作区的对话列表里就能看到这条问答并实时观看其进展。 + +该路由前缀 ``/api/open/`` 已在 ``auth_middleware._PUBLIC_PATH_PREFIXES`` 白名单里(且 +``csrf_middleware`` 豁免),**绕过登录鉴权**,以 ``"default"`` 用户桶运行;因 thread 带 +``metadata.taskId``(任务深链对话全局共享、按创建者解析文件路径),任何已登录用户打开该任务 +都能看到同一条会话。 + +请求示例:: + + POST /api/open/3qfx/ask + {"task_id": "2055"} + +返回:: + + {"thread_id": "…", "run_id": "…", "agent_id": "…", "status": "running", + "opening_text": "任务名称:…\n任务内容:…\n任务id(task_id)为2055"} +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +import httpx +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from app.gateway.routers.thread_runs import RunCreateRequest +from app.gateway.services import start_run +from deerflow.config import get_app_config + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/open/3qfx", tags=["open"]) + +# 与前端任务深链 goPath=3qfx → businessCode 的映射保持一致。 +_THREEQ_BUSINESS_CODE = "3Q" + + +class ThreeQAskRequest(BaseModel): + """3qfx 开放问答请求体。""" + + task_id: str = Field(..., description="任务id(cop-task-detail 的 taskId)", min_length=1) + message: str | None = Field( + default=None, + description="可选:自定义首条问题文本。留空(默认)则后端按 task_id 拉取任务详情自动拼开场白。", + ) + model_name: str | None = Field(default=None, description="可选:覆盖模型(models[] 中的 name)") + thinking_enabled: bool = Field(default=False, description="是否开启思考;默认关闭") + + +class ThreeQAskResponse(BaseModel): + thread_id: str + run_id: str + agent_id: str + status: str + opening_text: str + + +class CopTaskDetail(BaseModel): + """consumer ``cop-task-detail`` 返回的 ``data`` 字段(与前端 CopTaskDetail 对齐)。""" + + id: str | int | None = None + overview: str = "" + task_direction: str = "" + task_name: str = "" + task_content: str = "" + + +async def _resolve_threeq_agent_id(request: Request) -> str: + """取 ``business_code=="3Q"`` 配置的智能体 id;未配置 → 503。""" + store = getattr(request.app.state, "business_mapping_store", None) + if store is None: + raise HTTPException(status_code=503, detail="业务映射未就绪(business_mapping_store 不可用)。") + mapping = await store.get_mapping(_THREEQ_BUSINESS_CODE) + agent_id = (mapping or {}).get("agent_id") + if not agent_id: + raise HTTPException(status_code=503, detail="3Q(三情分析)业务尚未在「业务链条管理」里配置智能体。") + return str(agent_id) + + +def _parse_cop_task_detail(body: Any) -> CopTaskDetail: + """从 ``{data: {...}}`` 整包里抽出任务详情(字段缺失按空串)。""" + data = body.get("data") if isinstance(body, dict) else None + if not isinstance(data, dict): + return CopTaskDetail() + return CopTaskDetail( + id=data.get("id"), + overview=data.get("overview") if isinstance(data.get("overview"), str) else "", + task_direction=data.get("taskDirection") if isinstance(data.get("taskDirection"), str) else "", + task_name=data.get("taskName") if isinstance(data.get("taskName"), str) else "", + task_content=data.get("taskContent") if isinstance(data.get("taskContent"), str) else "", + ) + + +async def _fetch_cop_task_detail(task_id: str) -> CopTaskDetail: + """按 taskId 取任务详情。``cop_task_detail_test`` 为真走 mock;否则打真实 consumer 接口。""" + cfg = get_app_config().task_deeplink + if cfg.cop_task_detail_test: + return _parse_cop_task_detail((cfg.mock or {}).get("cop_task_detail")) + + url = (cfg.cop_task_detail_url or "").strip() + if not url: + raise HTTPException(status_code=503, detail="未配置任务详情接口(task_deeplink.cop_task_detail_url),无法按 id 拉取。") + try: + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.get( + url, + params={"taskId": task_id}, + headers={"Authorization": cfg.cop_task_detail_authorization or "Bearer admin"}, + ) + resp.raise_for_status() + body = resp.json() + except Exception as exc: # noqa: BLE001 — 开放接口需把上游错误归一成 502 + logger.warning("拉取 cop-task-detail 失败 (taskId=%s): %s", task_id, exc) + raise HTTPException(status_code=502, detail=f"拉取任务详情失败:{exc}") from exc + return _parse_cop_task_detail(body) + + +def build_opening_text(detail: CopTaskDetail, task_id: str) -> str: + """拼 3qfx 开场白,与前端 ``buildOpeningText``(3qfx 分支)逐行对齐。""" + parts = [ + f"任务名称:{detail.task_name}" if detail.task_name else "", + f"任务内容:{detail.task_content}" if detail.task_content else "", + f"任务id(task_id)为{task_id}" if task_id else "", + ] + return "\n".join(p for p in parts if p) + + +@router.post("/ask", response_model=ThreeQAskResponse) +async def ask_3qfx(body: ThreeQAskRequest, request: Request) -> ThreeQAskResponse: + """按 taskId 直接发起 3qfx 第一次问答;后台运行,立即返回 thread_id。""" + task_id = body.task_id.strip() + if not task_id: + raise HTTPException(status_code=422, detail="task_id 不能为空。") + + agent_id = await _resolve_threeq_agent_id(request) + + # 首条问题:默认按 id 自动拉取任务详情拼开场白;显式传 message 则用它覆盖。 + if body.message and body.message.strip(): + opening_text = body.message.strip() + else: + detail = await _fetch_cop_task_detail(task_id) + opening_text = build_opening_text(detail, task_id) + if not opening_text: + raise HTTPException(status_code=502, detail="任务详情为空,无法生成首条问题(可改用 message 显式传入)。") + + thread_id = str(uuid.uuid4()) + context: dict[str, Any] = {"agent_id": agent_id, "thinking_enabled": body.thinking_enabled} + if body.model_name: + context["model_name"] = body.model_name + + run_body = RunCreateRequest( + assistant_id=agent_id, + input={"messages": [{"role": "user", "content": opening_text}]}, + # taskId + agent_id 让该会话被 3qfx 任务工作区的 useTaskThreads 过滤命中(按任务+业务隔离), + # 且 metadata.taskId 触发后端「任务深链对话全局共享 / 按创建者解析文件」语义。 + metadata={"taskId": task_id, "agent_id": agent_id}, + context=context, + multitask_strategy="reject", + ) + + # enforce_access=False:开放接口已自行解析 admin 配置的 3Q 智能体,不再按当前(无)登录用户做可见性校验。 + record = await start_run(run_body, thread_id, request, enforce_access=False) + + return ThreeQAskResponse( + thread_id=record.thread_id, + run_id=record.run_id, + agent_id=agent_id, + status=record.status.value, + opening_text=opening_text, + ) diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 761ee3eb..0e9a48f1 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -528,6 +528,8 @@ async def start_run( body: Any, thread_id: str, request: Request, + *, + enforce_access: bool = True, ) -> RunRecord: """Create a RunRecord and launch the background agent task. @@ -559,7 +561,13 @@ async def start_run( disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_ t_pre_enforce = time.perf_counter() - await _enforce_agent_access(body, request) + # ``enforce_access=False`` is for intentionally open/unauthenticated entry + # points (e.g. /api/open/3qfx/ask) that resolve the agent themselves from an + # admin-configured mapping rather than from a per-user-visible assistant_id. + # Those run under the "default" bucket, where a user-owned unpublished agent + # would otherwise 404 the access check. + if enforce_access: + await _enforce_agent_access(body, request) t_post_enforce = time.perf_counter() try: diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 728fb43b..a87d12c1 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -435,6 +435,9 @@ task_deeplink: test: true # 目的树查询走 mock.purpose_detail 假数据 delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) + # 开放接口 POST /api/open/3qfx/ask 按 taskId 拉取任务详情拼开场白;上游不可达时打开下面开关走 mock.cop_task_detail。 + cop_task_detail_test: true + cop_task_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/cop-task-detail" purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" # rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。 delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData" @@ -486,3 +489,11 @@ task_deeplink: actionName: "" actionStatus: 25 narration: "" + # cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。 + cop_task_detail: + data: + id: 2055 + overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务" + taskDirection: "正向引导 + 风险预警" + taskName: "任务01:暑期文旅舆情综合研判" + taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。" 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 index f86d7374..9b9c6cea 100644 --- 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 @@ -41,6 +41,18 @@ class TaskDeeplinkConfig(BaseModel): default=False, description="为真时前端「行动→任务」解析走 mock.action_detail 假数据(不打真实接口)", ) + cop_task_detail_test: bool = Field( + default=False, + description="为真时开放接口 POST /api/open/3qfx/ask 按 taskId 取任务详情走 mock.cop_task_detail 假数据(上游 consumer 不可达时用)", + ) + cop_task_detail_url: str = Field( + default="", + description="任务详情接口完整 URL(开放接口 /api/open/3qfx/ask 后端按 taskId 拉取,追加 ?taskId=);与前端 cop-task-detail 同一上游。留空则该开放接口报 503。", + ) + cop_task_detail_authorization: str = Field( + default="Bearer admin", + description="拉取任务详情时下发的 Authorization 头(与前端 axios 拦截器一致,默认 Bearer admin)", + ) purpose_detail_url: str = Field( default="", description="目的树查询完整 URL,调用时追加 ?taskId=",