From 00181ebde8988d97fdb12e0bd8865e9111461a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 23 Jun 2026 20:27:46 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1):?= =?UTF-8?q?=20=E6=A8=A1=E6=9D=BF=E8=BD=AC=E6=8D=A2=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=BA=AF=E6=96=87=E6=9C=AC/=E5=90=ABJSON=E6=96=87=E6=9C=AC/?= =?UTF-8?q?=E7=BA=AFJSON=E4=B8=89=E7=A7=8D=E6=BA=90=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - template_fill.extract_embedded_json:字符串感知平衡扫描,从含 JSON 的文本里抠出 内嵌 {…}/[…](纯文本返回 None) - 调度器转换 prompt 预识别内嵌 JSON,单独标注「识别到的结构化数据」并保留原文; 纯文本则让模型按语义理解;conversion prompt 重写覆盖三种形态 - 内置 agent SOUL 说明三种源数据形态 - 文档同步 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- offline-backend-20260512/backend/CLAUDE.md | 2 +- .../template-json-builder/SOUL.md | 7 +- .../deerflow/runtime/scheduler/service.py | 37 +++++++--- .../runtime/scheduler/template_fill.py | 67 +++++++++++++++++++ 5 files changed, 103 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f68d459..cd244950 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) -- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source JSON is **any** shape — the agent's `SOUL.md` maps it to the template's schema skeleton. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, and dispatch. +- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, and dispatch. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (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. diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 599ea614..f7685098 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -300,7 +300,7 @@ Two on-demand/automatic refresh paths layer on top: **Unified output switches (`require_html` + `require_markdown`).** The task form no longer splits "???? / HTML ??" into separate kinds. `execution_context` now carries two **independent** booleans that may both be on: `require_html` (???? HTML) and `require_markdown` (???? Markdown). `_task_requires_html()` returns true for `require_html` **or** legacy `task_kind == "html_page"` (so old tasks need no data migration), and `_run_task_attempt()` dispatches to `_run_html_page_attempt` when it is true, else the regular agent attempt. When **both** switches are on, `_gather_html_data()` returns `(text, files)` ? the gather agent run is additionally told to save a `.md` file ? and `_run_html_page_attempt` validates it (`MarkdownRequirementError` on failure) and surfaces it alongside the generated page, so the run delivers both artifacts. The single main `prompt` doubles as the HTML page requirement (`html_config.page_prompt`); there is no separate page-prompt field. Tests: `tests/test_scheduled_task_require_html.py` + the markdown-coexistence cases in `tests/test_html_page_tasks.py`. -**Template-fill mode (JSON → 固定 HTML 模板填充).** A third execution mode, distinct from the free-form `require_html` render pipeline: convert a user-supplied JSON into a **fixed** HTML template's data structure and fill it. Triggered when `execution_context.template_config.template_id` is set (chosen in the task form after selecting the built-in **「模板数据转换器」** agent `template-json-builder`). `_task_template_id()` reads it and `_run_task_attempt()` dispatches to `_run_template_fill_attempt` **before** the `require_html` check. Pipeline: take source JSON (the task `prompt`, pasted or uploaded .json) → load the selected agent's `SOUL.md`+model and run a **direct** `_invoke_conversion_model` call (system=SOUL, human=conversion prompt carrying the template's compact **schema skeleton** + source) → `extract_json_object` → `template_fill.validate_data` (top-level keys + container types) → **on failure feed the structure errors + `field_coverage_gaps` back and retry up to `_MAX_TEMPLATE_FILL_RETRIES`=2** → `template_fill.fill_template` replaces the template's `const DATA={…}` line (and refreshes `MANIFEST.系统.更新时间`) → archive `index.html` + persist a `scheduled_html_pages` row → return `format:"html"` (reusing the existing HTML preview at `/runs/{id}/html`). Any terminal failure raises `TemplateFillError` → run marked failed (no retry, message shown). **Pure engine** `deerflow/runtime/scheduler/template_fill.py` (harness, no `app.*`): the template registry (`TEMPLATES`: id=`no1` name=`no.1` 台湾滨海防卫大屏, and id=`no2` name=`no.2` 台湾飞行部队平台 with **`convertible=False`** — a 「只展示」 template that renders its existing HTML **as-is**, no model/validation/fill, until conversion is wired in later; `_run_template_fill_attempt` short-circuits to `read_template_text` for it, and `list_templates`/`is_convertible` expose the flag so the form hides the JSON input), `list_templates`/`reference_data`/`schema_skeleton_json`/`validate_data`/`field_coverage_gaps`/`fill_template` — **the template's own embedded `DATA` is the schema source of truth** (parsed at load, 1-sample-per-array skeleton fed to the LLM, top-level structure as the validation basis); template HTML files live under `runtime/scheduler/templates//`, add a `TemplateSpec` to register a no.2. The agent is seeded like the AI-writing ones: `app/gateway/routers/_template_builder_seed.py` + `_template_builder_seed_assets/template-json-builder/{config.yaml,SOUL.md}`, called from the lifespan before `_sync_legacy_agents` (which upserts it into the `agents` table as built-in/published so it shows in `/api/agents` → the task form's 执行智能体 dropdown). Template list API: `GET /api/scheduled-tasks/html-templates` → `{templates:[{id,name,description}]}` (open). Frontend: when the agent dropdown = `template-json-builder` the form hides the HTML/Markdown switches and shows a 「HTML模板」 select (`useHtmlTemplates`) + a .json upload (reads file text into the prompt box). Tests: `tests/test_template_fill.py`, `tests/test_template_fill_attempt.py`, `tests/test_template_builder_seed.py`. +**Template-fill mode (JSON → 固定 HTML 模板填充).** A third execution mode, distinct from the free-form `require_html` render pipeline: convert a user-supplied JSON into a **fixed** HTML template's data structure and fill it. Triggered when `execution_context.template_config.template_id` is set (chosen in the task form after selecting the built-in **「模板数据转换器」** agent `template-json-builder`). `_task_template_id()` reads it and `_run_task_attempt()` dispatches to `_run_template_fill_attempt` **before** the `require_html` check. Pipeline: take the source (the task `prompt`, pasted or uploaded .json) — which may be **pure JSON, plain text, or text-with-embedded-JSON** (`template_fill.extract_embedded_json` pre-extracts any embedded `{…}`/`[…]` with a string-aware balanced scan and surfaces it as a labeled block while keeping the full original text; plain text → no block, the model reads it semantically) → load the selected agent's `SOUL.md`+model and run a **direct** `_invoke_conversion_model` call (system=SOUL, human=conversion prompt carrying the template's compact **schema skeleton** + the recognized-JSON block + source) → `extract_json_object` → `template_fill.validate_data` (top-level keys + container types) → **on failure feed the structure errors + `field_coverage_gaps` back and retry up to `_MAX_TEMPLATE_FILL_RETRIES`=2** → `template_fill.fill_template` replaces the template's `const DATA={…}` line (and refreshes `MANIFEST.系统.更新时间`) → archive `index.html` + persist a `scheduled_html_pages` row → return `format:"html"` (reusing the existing HTML preview at `/runs/{id}/html`). Any terminal failure raises `TemplateFillError` → run marked failed (no retry, message shown). **Pure engine** `deerflow/runtime/scheduler/template_fill.py` (harness, no `app.*`): the template registry (`TEMPLATES`: id=`no1` name=`no.1` 台湾滨海防卫大屏, and id=`no2` name=`no.2` 台湾飞行部队平台 with **`convertible=False`** — a 「只展示」 template that renders its existing HTML **as-is**, no model/validation/fill, until conversion is wired in later; `_run_template_fill_attempt` short-circuits to `read_template_text` for it, and `list_templates`/`is_convertible` expose the flag so the form hides the JSON input), `list_templates`/`reference_data`/`schema_skeleton_json`/`validate_data`/`field_coverage_gaps`/`fill_template` — **the template's own embedded `DATA` is the schema source of truth** (parsed at load, 1-sample-per-array skeleton fed to the LLM, top-level structure as the validation basis); template HTML files live under `runtime/scheduler/templates//`, add a `TemplateSpec` to register a no.2. The agent is seeded like the AI-writing ones: `app/gateway/routers/_template_builder_seed.py` + `_template_builder_seed_assets/template-json-builder/{config.yaml,SOUL.md}`, called from the lifespan before `_sync_legacy_agents` (which upserts it into the `agents` table as built-in/published so it shows in `/api/agents` → the task form's 执行智能体 dropdown). Template list API: `GET /api/scheduled-tasks/html-templates` → `{templates:[{id,name,description}]}` (open). Frontend: when the agent dropdown = `template-json-builder` the form hides the HTML/Markdown switches and shows a 「HTML模板」 select (`useHtmlTemplates`) + a .json upload (reads file text into the prompt box). Tests: `tests/test_template_fill.py`, `tests/test_template_fill_attempt.py`, `tests/test_template_builder_seed.py`. ### Per-user Prompts & UI Preferences diff --git a/offline-backend-20260512/backend/app/gateway/routers/_template_builder_seed_assets/template-json-builder/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_template_builder_seed_assets/template-json-builder/SOUL.md index f5624edf..f8af30de 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_template_builder_seed_assets/template-json-builder/SOUL.md +++ b/offline-backend-20260512/backend/app/gateway/routers/_template_builder_seed_assets/template-json-builder/SOUL.md @@ -7,7 +7,12 @@ 1. **目标结构骨架**:一份 JSON,展示了目标格式的**完整字段与类型**(每个数组只给 1 个 样例元素,但该元素已列出全部字段)。这是你必须严格对齐的契约。 -2. **源 JSON**:用户提供的原始数据,结构任意、字段名可能与目标不同、可能有多有少。 +2. **源数据**:用户提供的原始材料,形态可能是以下任意一种—— + - 一段 **JSON**(结构任意、字段名可能与目标不同、可能有多有少); + - 一段 **纯文本**(没有 JSON,需你理解其语义后据实提炼填充); + - **含 JSON 的文本**(正文里夹带 JSON——以其中的 JSON 为主、正文文字为补充说明)。 + 当系统已替你识别出内嵌的结构化数据时,会以「从源数据中识别到的结构化数据」单独给出, + **优先以它为准**,再结合原文里的其它说明。 3. (重试时) **上一轮的校验错误 / 缺失字段清单**:你必须据此修正后重新输出。 ## 转换规则 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py index 0880ed7a..eff446cd 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging import mimetypes import re @@ -1375,14 +1376,29 @@ class ScheduledTaskService: } @staticmethod - def _build_conversion_prompt(skeleton_json: str, source: str) -> str: - return ( - "请把下面的【源数据】转换为【目标结构】要求的 JSON。\n\n" - "【目标结构】(展示了完整字段与类型,其中数组只示意 1 个样例元素,实际请按源数据填充全部元素):\n" - "```json\n" + skeleton_json + "\n```\n\n" - "【源数据】:\n" + source + "\n\n" - "只输出一个 ```json 代码块,内含填充好的完整目标 JSON,不要任何解释文字。" - ) + def _build_conversion_prompt(skeleton_json: str, source: str, embedded_json: Any | None = None) -> str: + parts = [ + "请把下面的【源数据】转换为【目标结构】要求的 JSON。", + "源数据可能是一段 JSON、一段纯文本、或包含 JSON 的文本——" + "其中若有结构化 JSON 以 JSON 内容为准;若是纯文本请理解其语义后据实填充;" + "源里没有对应内容的字段按其类型留空(\"\" / [] / null),不要臆造。", + "", + "【目标结构】(展示了完整字段与类型,其中数组只示意 1 个样例元素,实际请按源数据填充全部元素):", + "```json\n" + skeleton_json + "\n```", + "", + ] + if embedded_json is not None: + # 已从源数据里识别出结构化 JSON,单独标注帮助(尤其弱)模型对齐。 + parts.append("【从源数据中识别到的结构化数据】(优先以此为准):") + parts.append("```json\n" + json.dumps(embedded_json, ensure_ascii=False) + "\n```") + parts.append("") + parts.append("【源数据原文】(含上面 JSON 之外的补充说明,一并参考):") + else: + parts.append("【源数据】:") + parts.append(source) + parts.append("") + parts.append("只输出一个 ```json 代码块,内含填充好的完整目标 JSON,不要任何解释文字。") + return "\n".join(parts) @staticmethod def _build_retry_prompt(base_prompt: str, errors: list[str], gaps: list[str]) -> str: @@ -1467,7 +1483,10 @@ class ScheduledTaskService: model_name = None skeleton = template_fill.schema_skeleton_json(template_id) - base_prompt = self._build_conversion_prompt(skeleton, source) + # 源数据可能是纯 JSON / 纯文本 / 含 JSON 的文本:先尽力抽出内嵌 JSON + # 单独标注,原文一并保留(纯文本则无内嵌 JSON,让模型按语义理解)。 + embedded_json = template_fill.extract_embedded_json(source) + base_prompt = self._build_conversion_prompt(skeleton, source, embedded_json) # 转换 → 校验 → 失败回喂重试。 last_errors: list[str] = [] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_fill.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_fill.py index f1274dfb..025168fe 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_fill.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_fill.py @@ -30,6 +30,73 @@ from functools import lru_cache from pathlib import Path from typing import Any +_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL | re.IGNORECASE) + + +def _scan_balanced(text: str, open_ch: str, close_ch: str) -> Any | None: + """从 text 里扫第一个**平衡**的 ``open_ch…close_ch`` 块并 ``json.loads``。 + + 字符串感知(跳过引号内的括号/转义),失败返回 None。供从「含 JSON 的文本」里 + 把 JSON 段抠出来用。 + """ + start = text.find(open_ch) + if start == -1: + return None + depth = 0 + in_string = False + escape = False + for i in range(start, len(text)): + ch = text[i] + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + continue + if ch == '"': + in_string = True + elif ch == open_ch: + depth += 1 + elif ch == close_ch: + depth -= 1 + if depth == 0: + blob = text[start : i + 1] + try: + return json.loads(blob) + except (ValueError, TypeError): + return None + return None + + +def extract_embedded_json(text: str | None) -> Any | None: + """尽力从一段文本里抽出内嵌的 JSON(对象或数组)。 + + 覆盖三种用户输入:纯 JSON(直接解析)、含 JSON 的文本(抠出第一个平衡的 + ``{…}`` 或 ``[…]``)、纯文本(返回 ``None``)。也识别 ```json fence。 + """ + if not text or not isinstance(text, str): + return None + s = text.strip() + fence = _FENCE_RE.search(s) + if fence: + inner = fence.group(1).strip() + try: + return json.loads(inner) + except (ValueError, TypeError): + s = inner # fence 内不是完整 JSON,继续往下扫 + try: + return json.loads(s) + except (ValueError, TypeError): + pass + # 取最靠前出现的那种括号块(对象优先于数组,谁先出现谁优先)。 + brace = _scan_balanced(s, "{", "}") + bracket = _scan_balanced(s, "[", "]") + if brace is not None and bracket is not None: + return brace if s.find("{") <= s.find("[") else bracket + return brace if brace is not None else bracket + # 模板资源根目录(随 git 发布,与本模块同级 ``templates/`` 子目录)。 _TEMPLATES_ROOT = Path(__file__).parent / "templates" From 6742346eb4bc8d42fbed7c39b9d3d8c3f3ce3511 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 23 Jun 2026 21:05:58 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/core/embed/embed-session.ts | 32 ++++++++++++++++++-- frontend-web/src/pages/PageRoutes.tsx | 18 +++++++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/frontend-web/src/core/embed/embed-session.ts b/frontend-web/src/core/embed/embed-session.ts index 6dcf0de1..8e0f8a32 100644 --- a/frontend-web/src/core/embed/embed-session.ts +++ b/frontend-web/src/core/embed/embed-session.ts @@ -39,6 +39,12 @@ export interface EmbedSession { theme: EmbedTheme; /** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */ hideJump: boolean; + /** + * 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。 + * 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要 + * 一个干净的全屏页面(如「大屏绘制」`?hideChrome=1`),其余行为与正常标签页完全一致。 + */ + hideChrome: boolean; /** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */ isolate: boolean; } @@ -69,6 +75,11 @@ export function readHideJumpParam(search: string): FlagParam { return readFlagParam(search, "hideJump"); } +/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */ +export function readHideChromeParam(search: string): FlagParam { + return readFlagParam(search, "hideChrome"); +} + /** 读 URL 里的 `isolate` 三态(独立会话登录)。 */ export function readIsolateParam(search: string): FlagParam { return readFlagParam(search, "isolate"); @@ -99,6 +110,7 @@ export function readEmbedSession(): EmbedSession | null { embed: p.embed === true, theme: p.theme === "dark" ? "dark" : "light", hideJump: p.hideJump === true, + hideChrome: p.hideChrome === true, isolate: p.isolate === true, }; } catch { @@ -109,7 +121,7 @@ export function readEmbedSession(): EmbedSession | null { /** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */ function writeEmbedSession(next: EmbedSession): void { try { - if (!next.embed && !next.hideJump && !next.isolate) { + if (!next.embed && !next.hideJump && !next.hideChrome && !next.isolate) { sessionStorage.removeItem(EMBED_SESSION_KEY); return; } @@ -129,6 +141,16 @@ export function isJumpBarHidden(): boolean { return readEmbedSession()?.hideJump === true; } +/** + * 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。 + * `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。 + * 与 `isEmbedActive()` 的差别:`hideChrome` 不强制主题、不隔离鉴权,只去 chrome。 + */ +export function isChromeHidden(): boolean { + const s = readEmbedSession(); + return s?.embed === true || s?.hideChrome === true; +} + /** * 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。 * `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`, @@ -146,7 +168,7 @@ export function isIsolatedSession(): boolean { export function syncEmbedSessionFromSearch(search: string): void { const cur = readEmbedSession() ?? - { embed: false, theme: "light" as EmbedTheme, hideJump: false, isolate: false }; + { embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false }; const next: EmbedSession = { ...cur }; const embedParam = readEmbedParam(search); @@ -161,6 +183,10 @@ export function syncEmbedSessionFromSearch(search: string): void { if (hideJumpParam === "on") next.hideJump = true; else if (hideJumpParam === "off") next.hideJump = false; + const hideChromeParam = readHideChromeParam(search); + if (hideChromeParam === "on") next.hideChrome = true; + else if (hideChromeParam === "off") next.hideChrome = false; + const isolateParam = readIsolateParam(search); if (isolateParam === "on") next.isolate = true; else if (isolateParam === "off") next.isolate = false; @@ -208,6 +234,6 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined export function setIsolatedSession(on: boolean): void { const cur = readEmbedSession() ?? - { embed: false, theme: "light" as EmbedTheme, hideJump: false, isolate: false }; + { embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false }; writeEmbedSession({ ...cur, isolate: on }); } diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index a49f984c..208f82bc 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -6,7 +6,12 @@ import { PageSidebarDark } from "@/components/page-sidebar-dark"; import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route"; import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled"; import { isWujieEnv } from "@/core/embed/wujie"; -import { isEmbedActive, readEmbedParam } from "@/core/embed/embed-session"; +import { + isEmbedActive, + isChromeHidden, + readEmbedParam, + readHideChromeParam, +} from "@/core/embed/embed-session"; import { useTheme } from "@/contexts/ThemeContext"; import { useLocation } from "react-router-dom"; import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu"; @@ -44,6 +49,13 @@ function PageLayout() { () => isEmbedActive() || readEmbedParam(location.search) === "on", [location.pathname, location.search], ); + // `?hideChrome=1`:仅去全局导航栏 + 侧边栏(不强制主题/不隔离鉴权),用于「大屏绘制」等想要 + // 干净全屏的页面。与 embed 一样按标签页 sessionStorage 持久化,跨登录跳转/页内导航存活;非置位 + // 标签页恒为 false,不受影响。按 location 重算以跟随页内导航。 + const chromeHidden = useMemo( + () => isChromeHidden() || readHideChromeParam(location.search) === "on", + [location.pathname, location.search], + ); // 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页, // 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false // (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome** @@ -52,7 +64,9 @@ function PageLayout() { const pageSidebarAllowed = (forceChromeForDashboardAgent || chromeByPreference) && !isChatsIframeWorkspacePath(location.pathname) && - !embedActive; + !embedActive && + // `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。 + !chromeHidden; // h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100% // 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套 From 151384b26d24d3f3c38a234195dd3ff44f10a552 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 23 Jun 2026 21:21:49 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- .../dashboard-agent/Disposal.tsx | 40 ++++++++++++++---- .../pages/DashboardAgentPage.tsx | 41 +++++++++++++++---- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1ea3101b..68ca3fe5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) - **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`, **plus its own「清除记录」button** (`ActionClearButton`, under 新建对话) that deletes **by 行动id**: opening its confirm dialog first runs a「行动历史是否存在」pre-check (`fetchActionHistoryExists`; query endpoint `task_deeplink.action_history_url` is **address-TBD** — empty ⇒ unknown, does **not** block the delete; once a definite「无历史」comes back it disables 确认清除), then deletes via the **shared** delete endpoint `task_deeplink.delete_url` (migrated to `dropSixOrSevenData`, used by **both** rwfx + xdfx — Six=6BF/rwfx, Seven=7BF/xdfx): `deleteTaskPurpose(id, {idKind})` sends rwfx `?taskId=` (omits actionId) / xdfx `?actionId=` (omits taskId) — the unused param is **absent from the URL**, not null. `deleteTaskPurpose` + `fetchActionHistoryExists` live in `core/auth/task-purpose.ts`. **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only), **but gated by the 3q precheck**: on entering the rwfx workspace `RwfxSqGateProvider` (`task-components/rwfx-sq-gate.tsx`, wraps the workspace, rwfx only) calls `fetchSqReportStatus(taskId)` — `incomplete`(3q 无数据) pops the「3q 未完成,是否前往 3qfx」dialog AND **holds the auto-send**; the question is only sent when the user picks「暂不」(or dismisses — i.e. does NOT go to 3q analysis), while「前往 3qfx」navigates and discards the send. `complete`/`unknown` send immediately (no dialog). The panel's auto-start calls `useRwfxSqGate().gateStart(onStart)` instead of `onStart()`; the manual「开始问答」/覆盖确认 path is unchanged (ungated). This replaced the old standalone `RwfxSqReportGate` (which popped the same dialog but did not gate the auto-send). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. -- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。 +- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(2.5) **`?hideChrome=1`** — **仅**去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权(区别于 `embed`:只想要一个干净全屏页面,如「大屏绘制」`/page/strategy/dashboard-agent?taskId=…&hideChrome=1`)。新增 `EmbedSession.hideChrome` 字段 + `readHideChromeParam()` + `isChromeHidden()`(取 `embed || hideChrome` 之并),与 `hideJump` 完全对称;`PageRoutes.tsx` 的 `PageLayout` 把 `chromeHidden`(`isChromeHidden() || readHideChromeParam(location.search)==="on"`)并入 `pageSidebarAllowed`,且其**优先级高于** dashboard-agent 路由原本的强制全局 chrome(`forceChromeForDashboardAgent`)。`hideChrome=0` 可显式关闭。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。 - **操作日志(Y-Log)上报** — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,**前端直连**外部接口 `POST {write_url}`(默认 `https://ch1.b.uat.4.cn/api/y-log/write`)记一条审计日志。核心模块 `src/core/log/`:`y-log.ts`(`writeYLog(params)` + `OptType` 枚举 0/1/3..12;**异步即发即忘、永不 await、永不抛错、失败静默**,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动 `JSON.stringify` + 50k 裁剪、`optResult` 恒 0 只报成功),`y-log-config.ts`(从公开只读 `GET /api/public/config/y-log` 拉 `{enabled, writeUrl}`,模块级缓存 + 去重 + 默认兜底;`App.tsx` `AppProviders` 启动预热 `fetchYLogConfig()`),`y-log-token.ts`(token1 = token 登录的 `?authToken=`,经 [[scoped-storage]] 的 `authStorage*` 存取,嵌入态自动隔离)。**双门控**:仅当①存在 token1(即用 token 登录,`LoginPage` 成功后 `persistYLogToken(authToken)`,登出 `account-settings-page.tsx` 先报后清 token1)且②配置 `enabled && writeUrl` 才上报;否则一律不发。`optDetail` 统一「B1-CM助手-…」(**禁内部框架名**)。埋点**广覆盖**各 API client 的成功路径——原则是「一个用户操作(查看/查询/新增/编辑/删除/启停/导入导出)= 一条日志」,每个 API client 的**单条详情 GET(查看→view 4)/ 列表查询 GET(search 3)/ 新增 POST(create 9)/ 改 PUT·PATCH(edit 5)/ 删 DELETE(delete 6)/ 启停(enabled 11 / disabled 10)/ 导入导出(import 7 / export 8)**都各落一条,仅跳过高频 typeahead/状态轮询/纯 bootstrap config 读。覆盖文件:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps,business-mapping}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs,recommend,chains,multi-agent,dashboard-sessions}.ts`、`core/{agents,scheduled-tasks,knowledge,positions,tags,studio,system-settings,memory,mcp,models,custom-prompts,user-preferences,notifications,recommended-questions,user-qa,curator,admin,thread-shares,roundtable-draft-shares,tool-metrics,llm-metrics,note,artifacts}/api.ts`、`core/api/markdown-docx-export.ts`。**问答(Q&A)改为「一次大模型调用 = 一条日志」**:在共享的 `core/threads/hooks.ts` `useThreadStream` 的 `onLangChainEvent` 里监听 `on_chat_model_end`,每次模型调用结束就上报一条(optType 12,`reqParam` 取本次 input 的最后一条 user 文本 + 模型名,`returnParam` 取本次输出文本)——一个问答轮次里 lead agent / 子代理的**每次** LLM 调用都各记一条,覆盖所有走 `useThreadStream` 的页面(`ChatPage`/`AgentChatPage`/创建智能体/iframe/会商等);`ChatPage`/`AgentChatPage` 的 `onFinish` 仍额外保留一条轮次汇总。后端仅新增公开只读配置:`deerflow/config/y_log_config.py`(`YLogConfig{enabled,write_url}`)挂到 `AppConfig.y_log`,`app/gateway/routers/public_config.py` 加 `GET /api/public/config/y-log`,地址写在 `config.yaml` 的 `y_log` 段(mtime 热重载,前端无需重建);**不做后端代理**。Tests:`tests/test_y_log_config.py`。 ### Configuration diff --git a/frontend-web/src/roundtable-planning/dashboard-agent/Disposal.tsx b/frontend-web/src/roundtable-planning/dashboard-agent/Disposal.tsx index 875e302b..c641b947 100644 --- a/frontend-web/src/roundtable-planning/dashboard-agent/Disposal.tsx +++ b/frontend-web/src/roundtable-planning/dashboard-agent/Disposal.tsx @@ -230,23 +230,32 @@ function DisposalReal({ data }: { data: DisposalData }) { key={i} className="rounded-md border border-slate-200 bg-slate-50/60 p-4 dark:border-[var(--dash-border)] dark:bg-[var(--dash-elev)]" > - {/* 标题行:序号 + 建议内容 + 口径基调 */} -
+ {/* 标题行:序号 + 建议内容(基调移到下方独立成行,长文不再挤压标题) */} +
{i + 1}
{d.content || "—"}
- {d.tone && ( - - {d.tone} - - )}
+ {/* 基调:拆成关键词小药丸,整行自适应换行——长基调不再被压成右侧一长条。 + 与序号对齐缩进(序号 20px + gap 10px = 30px)。 */} + {d.tone && ( +
+ + 基调 + + {splitTone(d.tone).map((t, ti) => ( + + {t} + + ))} +
+ )} {/* 其余字段:带标签两列网格 */} {(d.method || d.effect || d.subject || d.basis) && ( -
+
{d.method && } {d.effect && } {d.subject && } @@ -262,6 +271,21 @@ function DisposalReal({ data }: { data: DisposalData }) { ); } +/** + * 把「基调」串按常见分隔符(顿号 / 逗号 / 斜杠 / 分号 / 竖线 / 空白)拆成关键词数组,去空去重。 + * 整串无分隔符(单个词或一句话)则原样返回单个元素——既支持「悲愤、追责、人道主义审判」拆成 + * 三枚药丸,也兼容只有一个基调词的情况。 + */ +function splitTone(raw: string): string[] { + const parts = raw + .split(/[、,,//;;||\s]+/) + .map((s) => s.trim()) + .filter(Boolean); + const seen = new Set(); + const uniq = parts.filter((p) => (seen.has(p) ? false : (seen.add(p), true))); + return uniq.length > 0 ? uniq : [raw.trim()]; +} + /** 处置建议卡片里的「标签 + 值」字段(标签红色小字,值正文)。 */ function Field({ label, value }: { label: string; value: string }) { const ds = useDesensitize(); diff --git a/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx b/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx index bbafd103..0432763b 100644 --- a/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx @@ -15,7 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; -import { Loader2, RefreshCw, Sparkles } from "lucide-react"; +import { Inbox, Loader2, RefreshCw, Sparkles } from "lucide-react"; import { fetchDashboardInputJson } from "@/core/auth/dashboard-input"; import { readCopTaskDetail } from "@/core/auth/task-deeplink"; @@ -77,6 +77,16 @@ function deepDesensitize(value: T, fn: (s: string) => string): T { type DashView = "chat" | "page"; type RenderMode = "app" | "html"; +/** + * 战情报告是否含可展示内容。`parseSqReport` **永远**返回非 null 的 SqReport 对象(各类目可为 null), + * 故判空必须看「是否至少有一个类目非空」,不能用 `!!report`——否则全类目皆空时仍误判为有数据, + * 进而渲染出 DEMO 兜底的大屏标题 + 空导航 Tab。全空 → 整页走空状态(不渲染大屏标题/导航/页脚)。 + */ +function sqReportHasContent(r: SqReport | null): boolean { + if (!r) return false; + return !!(r.enemy || r.ours || r.environment || r.judgement || r.decision || r.disposal); +} + /** * 静态页面阶段开关(先按设计图搭总览版式,动态数据后续再接): * - 默认落在「大屏页面」视图,直接展示 SituationOverview 静态版式; @@ -284,9 +294,10 @@ export default function DashboardAgentPage() { } }, [sqReport, desensitizeText]); - // 是否有可展示的真实数据:战情报告(safeReport)或已生成的大屏(report-json / HTML)。 - // 三者皆无 → 不渲染大屏的导航栏与标题,仅显示「暂无数据」。 - const hasData = !!safeReport || !!dashboardData || !!dashboardHtml; + // 是否有可展示的真实数据:战情报告**至少一个类目非空**(safeReport 恒为非 null 对象,不能用 + // `!!safeReport` 判空,见 sqReportHasContent),或已生成的大屏(report-json / HTML)。 + // 三者皆无 → 不渲染大屏的导航栏与标题,改显示整页空状态。 + const hasData = sqReportHasContent(safeReport) || !!dashboardData || !!dashboardHtml; // 战情多页:总览 + 六个详情页;详情页传入对应的(脱敏后)真实数据(无则页面回退占位)。 // 导航 Tab 文本(label)也经 desensitizeText 脱敏后再传给 DashboardApp 渲染导航栏。 @@ -410,9 +421,25 @@ export default function DashboardAgentPage() {
) : view === "page" ? ( !hasData ? ( - // 无数据:隐藏大屏导航栏与顶部标题,仅显示「暂无数据」。 -
- 暂无数据 + // 无数据:不渲染大屏标题/导航 Tab/页脚,整页改为一个干净的空状态(图标 + 文案 + 重试)。 +
+
+
+
+ +
+
+

+ 暂无大屏数据 +

+
) : renderMode === "html" && htmlBlobUrl ? (