Merge branch 'master' into dhk-1
This commit is contained in:
commit
85aa242fd5
48 files changed
+1818
-207
No files matched your search
@@ -110,3 +110,6 @@ offline-backend-20260512/backend/uv.lock
|
||||
|
||||
# Python bytecode cache
|
||||
__pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -0,0 +1,102 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI super agent platform. The repo contains two sub-projects:
|
||||
|
||||
- `offline-backend-20260512/backend/` — Python backend (FastAPI Gateway + LangGraph agent runtime)
|
||||
- `frontend-web/` — Vite + React 19 frontend (TypeScript, pnpm)
|
||||
|
||||
Managed together via shell scripts in `scripts/`.
|
||||
|
||||
## Commands
|
||||
|
||||
### Full Application (from repo root)
|
||||
|
||||
```bash
|
||||
./scripts/start-all.sh # Start backend + frontend as background daemons
|
||||
./scripts/stop-all.sh # Stop both
|
||||
./scripts/restart-all.sh # Restart both
|
||||
./scripts/status.sh # Check running status
|
||||
```
|
||||
|
||||
Logs and PID files are written to `.runtime/logs/` and `.runtime/pids/`.
|
||||
|
||||
### Backend Only (from `offline-backend-20260512/backend/`)
|
||||
|
||||
```bash
|
||||
make install # Install Python dependencies (uv)
|
||||
make dev # Run Gateway API with hot-reload on port 8001
|
||||
make gateway # Run Gateway API without reload
|
||||
make test # Run all tests: PYTHONPATH=. uv run pytest tests/ -v
|
||||
make lint # Lint with ruff
|
||||
make format # Format with ruff
|
||||
```
|
||||
|
||||
Run a single test file:
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
|
||||
```
|
||||
|
||||
### Frontend Only (from `frontend-web/`)
|
||||
|
||||
```bash
|
||||
pnpm install # Install dependencies
|
||||
pnpm dev # Start Vite dev server on port 5174
|
||||
pnpm build # Production build
|
||||
pnpm typecheck # TypeScript type checking (tsc --noEmit)
|
||||
```
|
||||
|
||||
## Service Ports
|
||||
|
||||
| Service | Port | Notes |
|
||||
|---------|------|-------|
|
||||
| Backend (Gateway API) | 8001 | FastAPI + LangGraph runtime |
|
||||
| Frontend (Vite) | 5174 | React SPA |
|
||||
|
||||
Frontend connects to backend via env vars `VITE_BACKEND_BASE_URL` and `VITE_LANGGRAPH_BASE_URL` (injected by `scripts/start-frontend.sh`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
The backend (`offline-backend-20260512/backend/`) is split into two layers:
|
||||
|
||||
- **`packages/harness/deerflow/`** — publishable agent framework (`deerflow.*` imports): LangGraph lead agent, middleware chain, sandbox, tools, MCP, memory, models, skills. **Never imports from `app.*`.**
|
||||
- **`app/`** — application layer (`app.*` imports): FastAPI Gateway routers, IM channel integrations (Feishu, Slack, Telegram, DingTalk).
|
||||
|
||||
The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph entry point. It wraps a middleware chain (~18 middlewares assembled in strict order) around LLM calls, handling: thread isolation, sandbox lifecycle, uploads, memory, plan mode, vision, subagent delegation, and loop detection.
|
||||
|
||||
**Middleware order matters** — middlewares are appended in a fixed sequence; `ClarificationMiddleware` must always be last. See `offline-backend-20260512/backend/AGENTS.md` for the full ordered list.
|
||||
|
||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||
|
||||
### Frontend
|
||||
|
||||
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)
|
||||
- **轻应用中心 (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-<appId>`). 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. 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/`
|
||||
- **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/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `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 staLine truncated
|
||||
- **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 提示。
|
||||
|
||||
### Configuration
|
||||
|
||||
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
|
||||
|
||||
## Detailed Backend Reference
|
||||
|
||||
The backend has its own comprehensive documentation at:
|
||||
- `offline-backend-20260512/backend/AGENTS.md` — architecture, middleware chain, all API routes, config schema, sandbox/memory/subagent systems
|
||||
- `offline-backend-20260512/backend/README.md` — setup guide, technology stack
|
||||
@@ -89,8 +89,9 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **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/`
|
||||
- **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/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `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 staLine truncated
|
||||
- **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/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `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 staLine truncated
|
||||
- **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 提示。
|
||||
- **操作日志(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 的成功路径:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs}.ts`、`core/{agents,scheduled-tasks,knowledge}/api.ts`、`core/api/markdown-docx-export.ts`,以及问答完成钩子(`ChatPage`/`AgentChatPage` 的 `useThreadStream` `onFinish`,用最终汇总文本作 `returnParam`,optType 12)。后端仅新增公开只读配置:`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
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FontScaleApplier } from "@/components/font-scale";
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { fetchYLogConfig } from "@/core/log/y-log-config";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { ChatProvider } from "@/contexts/ChatContext";
|
||||
@@ -50,6 +51,11 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
syncEmbedSessionFromSearch(location.search);
|
||||
}, [location.search]);
|
||||
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
|
||||
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
|
||||
useEffect(() => {
|
||||
void fetchYLogConfig();
|
||||
}, []);
|
||||
const forcedTheme = useMemo(() => {
|
||||
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
|
||||
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
|
||||
|
||||
@@ -826,7 +826,13 @@ const DocumentUploadPopupContent: FC<{
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{listData.length === 0 && <Empty />}
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
listData.length === 0 && <Empty />
|
||||
)}
|
||||
{searchType === "human" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item: listDataItem, index: number) => {
|
||||
|
||||
@@ -545,7 +545,12 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
|
||||
</div>
|
||||
)}
|
||||
<div className="p-[10px]">
|
||||
<div className="grid grid-cols-4 gap-[10px] user-list">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
|
||||
{userList.map((item, index) => (
|
||||
<div key={index} className="user-item p-[10px] relative">
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
|
||||
@@ -32,6 +32,10 @@ interface AgentSelectorProps {
|
||||
disabled?: boolean;
|
||||
/** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */
|
||||
compact?: boolean;
|
||||
/** 图标模式:用于 iframe 等底栏场景,仅显示机器人图标和可选清空按钮。 */
|
||||
triggerVariant?: "default" | "icon";
|
||||
iconButtonClassName?: string;
|
||||
iconActiveClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +52,9 @@ export function AgentSelector({
|
||||
onAgentChange,
|
||||
disabled,
|
||||
compact = false,
|
||||
triggerVariant = "default",
|
||||
iconButtonClassName,
|
||||
iconActiveClassName,
|
||||
}: AgentSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
@@ -79,48 +86,84 @@ export function AgentSelector({
|
||||
};
|
||||
|
||||
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
|
||||
const iconMode = triggerVariant === "icon";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
compact
|
||||
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
: selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<BotIcon
|
||||
className={cn("size-3", compact && "text-[var(--primary)]")}
|
||||
/>
|
||||
<span className="max-w-[140px] truncate text-xs font-normal">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
{iconMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||
className={cn(
|
||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||
selectedAgent && iconActiveClassName,
|
||||
iconButtonClassName,
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-4" />
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
compact
|
||||
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
: selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<BotIcon
|
||||
className={cn("size-3", compact && "text-[var(--primary)]")}
|
||||
/>
|
||||
<span className="max-w-[140px] truncate text-xs font-normal">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
)}
|
||||
</PromptInputButton>
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
</PromptInputButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
||||
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { clearYLogToken } from "@/core/log/y-log-token";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@@ -40,7 +42,15 @@ export function AccountSettingsPage() {
|
||||
const displayName = getAccountDisplayName(auth);
|
||||
|
||||
const handleLogout = () => {
|
||||
// 操作日志(Y-Log):在清除 token1 之前上报登出(此时 token1 还在),keepalive 保证发出。
|
||||
writeYLog({
|
||||
optType: OptType.logout,
|
||||
optDetail: "B1-CM助手-登出",
|
||||
url: "/api/v1/auth/logout",
|
||||
keepalive: true,
|
||||
});
|
||||
clearStoredAuth();
|
||||
clearYLogToken();
|
||||
// Match the rest of the app's logout flow: blow away the cookie session
|
||||
// by hitting the gateway logout endpoint and then forcing a full reload
|
||||
// so any cached query state goes too.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
@@ -58,6 +59,13 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-智能体列表",
|
||||
url: `/api/agents${query ? `?${query}` : ""}`,
|
||||
reqParam: params,
|
||||
returnParam: data.agents,
|
||||
});
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
@@ -76,12 +84,20 @@ export async function listAgentsPaginated(
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
return {
|
||||
const result: PaginatedAgents = {
|
||||
agents: data.agents,
|
||||
total: data.total ?? data.agents.length,
|
||||
page: data.page ?? params.page ?? 1,
|
||||
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
|
||||
};
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-智能体列表",
|
||||
url: `/api/agents${query ? `?${query}` : ""}`,
|
||||
reqParam: params,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listAllAgentsAdmin(): Promise<Agent[]> {
|
||||
@@ -91,6 +107,12 @@ export async function listAllAgentsAdmin(): Promise<Agent[]> {
|
||||
throw new Error(err.detail ?? `Failed to load agents: ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-智能体列表",
|
||||
url: `/api/agents?admin=true`,
|
||||
returnParam: data.agents,
|
||||
});
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
@@ -110,7 +132,15 @@ export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to create agent: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
const created = (await res.json()) as Agent;
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "B1-CM助手-智能体-新建",
|
||||
url: `/api/agents`,
|
||||
reqParam: request,
|
||||
returnParam: created,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function updateAgent(
|
||||
@@ -126,7 +156,15 @@ export async function updateAgent(
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to update agent: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
const updated = (await res.json()) as Agent;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-修改",
|
||||
url: `/api/agents/${id}`,
|
||||
reqParam: { id, ...request },
|
||||
returnParam: updated,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteAgent(id: string): Promise<void> {
|
||||
@@ -134,6 +172,12 @@ export async function deleteAgent(id: string): Promise<void> {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "B1-CM助手-智能体-删除",
|
||||
url: `/api/agents/${id}`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
|
||||
@@ -150,6 +194,12 @@ export async function favoriteAgent(id: string): Promise<void> {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to favorite agent: ${res.statusText}`);
|
||||
}
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-收藏",
|
||||
url: `/api/agents/${id}/favorite`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function unfavoriteAgent(id: string): Promise<void> {
|
||||
@@ -160,6 +210,12 @@ export async function unfavoriteAgent(id: string): Promise<void> {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
|
||||
}
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-取消收藏",
|
||||
url: `/api/agents/${id}/favorite`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function pinAgent(id: string): Promise<void> {
|
||||
@@ -170,6 +226,12 @@ export async function pinAgent(id: string): Promise<void> {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`);
|
||||
}
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-置顶",
|
||||
url: `/api/agents/${id}/pin`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function unpinAgent(id: string): Promise<void> {
|
||||
@@ -180,6 +242,12 @@ export async function unpinAgent(id: string): Promise<void> {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`);
|
||||
}
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-取消置顶",
|
||||
url: `/api/agents/${id}/pin`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
@@ -197,6 +265,12 @@ export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
err.detail ?? `Failed to reorder featured agents: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-精选排序",
|
||||
url: `/api/agents/featured/order`,
|
||||
reqParam: { agent_ids: agentIds },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPrivateAgents(password: string): Promise<Agent[]> {
|
||||
@@ -215,6 +289,12 @@ export async function listPrivateAgents(password: string): Promise<Agent[]> {
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-私有广场智能体列表",
|
||||
url: `/api/agents/private-square/list`,
|
||||
returnParam: data.agents,
|
||||
});
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
@@ -261,12 +341,20 @@ export async function listPrivateAgentsPaginated(
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
return {
|
||||
const result: PaginatedAgents = {
|
||||
agents: data.agents,
|
||||
total: data.total ?? data.agents.length,
|
||||
page: data.page ?? params.page ?? 1,
|
||||
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
|
||||
};
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-私有广场智能体列表",
|
||||
url: `/api/agents/private-square/list`,
|
||||
reqParam: { ...params, password: undefined },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function setAgentPrivate(
|
||||
@@ -287,7 +375,15 @@ export async function setAgentPrivate(
|
||||
err.detail ?? `Failed to update agent privacy: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
const updated = (await res.json()) as Agent;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-设置私有",
|
||||
url: `/api/agents/${id}/private`,
|
||||
reqParam: { id, is_private: isPrivate },
|
||||
returnParam: updated,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function setAgentFeatured(
|
||||
@@ -308,7 +404,15 @@ export async function setAgentFeatured(
|
||||
err.detail ?? `Failed to set featured: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
const updated = (await res.json()) as Agent;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-设置精选",
|
||||
url: `/api/agents/${id}/featured`,
|
||||
reqParam: { id, order },
|
||||
returnParam: updated,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export interface AgentSkillsSyncRemoval {
|
||||
@@ -333,7 +437,14 @@ export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to sync agent skills: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<AgentSkillsSyncResult>;
|
||||
const result = (await res.json()) as AgentSkillsSyncResult;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-智能体-同步技能",
|
||||
url: `/api/agents/admin/sync-skills`,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface KnowledgeIngestAttempt {
|
||||
@@ -371,7 +482,15 @@ export async function publishArtifactToKnowledge(request: {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `发布入库失败: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<KnowledgeIngestPublishResult>;
|
||||
const result = (await res.json()) as KnowledgeIngestPublishResult;
|
||||
writeYLog({
|
||||
optType: OptType.export,
|
||||
optDetail: "B1-CM助手-智能体-产物发布入库",
|
||||
url: `/api/knowledge-ingest/publish`,
|
||||
reqParam: request,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function checkAgentName(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
|
||||
function filenameFromContentDisposition(header: string | null): string | null {
|
||||
@@ -79,13 +80,31 @@ export async function downloadMarkdownDocxFromBackend(options: {
|
||||
|
||||
if (response.status === 404) {
|
||||
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
|
||||
writeYLog({
|
||||
optType: OptType.export,
|
||||
optDetail: "B1-CM助手-导出-Word",
|
||||
url: `${getBackendBaseURL()}/api/writing/export/docx`,
|
||||
reqParam: { title: options.title?.trim() || undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadBlobResponse(response, fallbackFilename);
|
||||
writeYLog({
|
||||
optType: OptType.export,
|
||||
optDetail: "B1-CM助手-导出-Word",
|
||||
url: `${getBackendBaseURL()}/api/writing/export/docx`,
|
||||
reqParam: { title: options.title?.trim() || undefined },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
|
||||
writeYLog({
|
||||
optType: OptType.export,
|
||||
optDetail: "B1-CM助手-导出-Word",
|
||||
url: `${getBackendBaseURL()}/api/writing/export/docx`,
|
||||
reqParam: { title: options.title?.trim() || undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -23,8 +23,10 @@ export interface TaskDeeplinkConfig {
|
||||
actionTest: boolean;
|
||||
/** 目的树查询完整 URL(调用追加 ?taskId=)。 */
|
||||
purposeDetailUrl: string;
|
||||
/** 删除完整 URL(调用追加 ?taskId=)。 */
|
||||
/** 删除完整 URL(rwfx 与 xdfx 共用同一接口):rwfx 追加 ?taskId=、xdfx 追加 ?actionId=(另一参数不传)。 */
|
||||
deleteUrl: string;
|
||||
/** xdfx「行动历史是否存在」查询完整 URL(调用追加 ?actionId=);留空 → 前端视作无历史、不阻断删除(地址待提供)。 */
|
||||
actionHistoryUrl: string;
|
||||
/** B 系统树形完整编辑页 URL(跳转追加 ?id=)。 */
|
||||
treemapEditUrl: string;
|
||||
/** B 系统树形下一步页 URL(跳转追加 ?id=)。 */
|
||||
@@ -50,10 +52,12 @@ export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = {
|
||||
deleteTest: false,
|
||||
actionTest: false,
|
||||
purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail",
|
||||
deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete",
|
||||
deleteUrl: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData",
|
||||
treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit",
|
||||
treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next",
|
||||
actionDetailUrl: "https://ch1.b.uat.4.cn/consumer/plan/action",
|
||||
// 行动历史查询地址待提供:空字符串 → 前端不查询、视作无历史(不阻断删除)。
|
||||
actionHistoryUrl: "",
|
||||
detail3qUrl: "",
|
||||
mock: {},
|
||||
};
|
||||
@@ -74,6 +78,8 @@ function normalize(body: unknown): TaskDeeplinkConfig {
|
||||
treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl),
|
||||
treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl),
|
||||
actionDetailUrl: str(o.action_detail_url, d.actionDetailUrl),
|
||||
// action_history_url 允许为空(地址待提供):空 → 前端不查询、视作无历史。不回退默认。
|
||||
actionHistoryUrl: typeof o.action_history_url === "string" ? o.action_history_url : d.actionHistoryUrl,
|
||||
// detail_3q_url 允许为空(地址待定),不回退默认;只在配了非空时才生效。
|
||||
detail3qUrl: typeof o.detail_3q_url === "string" ? o.detail_3q_url : d.detail3qUrl,
|
||||
mock: {
|
||||
|
||||
@@ -254,20 +254,35 @@ export interface DeleteTaskPurposeResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务目的树(覆盖前清空 / 手动清除任务记录都走这里)。``delete_test=true`` 时
|
||||
* 用后端配置里的假数据(接口未开发完时用);否则打真实接口。
|
||||
* 删除记录的「按哪种 id 删」:
|
||||
* - ``task``(默认,rwfx):传 ``?taskId=``,不传 actionId;
|
||||
* - ``action``(xdfx 行动分析):传 ``?actionId=``,不传 taskId。
|
||||
* rwfx 与 xdfx 共用同一个删除接口(``delete_url`` = dropSixOrSevenData);
|
||||
* 「不传」= 该参数**根本不出现在 URL 里**(只把要传的那个 key 放进 params,axios 自然省略另一个),
|
||||
* 不是传空值/None。
|
||||
*/
|
||||
export type DeleteIdKind = "task" | "action";
|
||||
|
||||
/**
|
||||
* 删除任务/行动记录(rwfx 覆盖前清空 / 手动清除 / xdfx 清除行动记录都走这里)。
|
||||
* ``delete_test=true`` 时用后端配置里的假数据(接口未开发完时用);否则打真实接口
|
||||
* (rwfx 传 ``taskId``、xdfx 传 ``actionId``,见 ``DeleteIdKind``)。
|
||||
*/
|
||||
export async function deleteTaskPurpose(
|
||||
taskId: string,
|
||||
id: string,
|
||||
opts?: { idKind?: DeleteIdKind },
|
||||
): Promise<DeleteTaskPurposeResult> {
|
||||
if (!taskId) return { message: "缺少 taskId", success: false };
|
||||
const idKind = opts?.idKind ?? "task";
|
||||
if (!id) return { message: idKind === "action" ? "缺少 actionId" : "缺少 taskId", success: false };
|
||||
const cfg = await fetchTaskDeeplinkConfig();
|
||||
if (cfg.deleteTest) {
|
||||
const m = cfg.mock.delete ?? {};
|
||||
return { message: typeof m.message === "string" ? m.message : "", success: m.success === true };
|
||||
}
|
||||
if (!cfg.deleteUrl) return { message: "缺少删除接口地址", success: false };
|
||||
const body = await requestCrudService.get(cfg.deleteUrl, { taskId });
|
||||
// 只放要传的那个 id key:另一参数直接不出现在 URL(axios 不会序列化缺省 key)。
|
||||
const params = idKind === "action" ? { actionId: id } : { taskId: id };
|
||||
const body = await requestCrudService.get(cfg.deleteUrl, params);
|
||||
const obj =
|
||||
body && typeof body === "object" ? (body as unknown as Record<string, unknown>) : {};
|
||||
return {
|
||||
@@ -276,6 +291,36 @@ export async function deleteTaskPurpose(
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActionHistoryResult {
|
||||
/** 是否存在可删除的「行动历史」记录。 */
|
||||
exists: boolean;
|
||||
/** 是否成功判定。接口未配置 / 请求失败 → false(调用方据此**不**做「无历史」拦截,仍允许删除)。 */
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 xdfx「行动历史是否存在」(清除行动记录前的预检),按「行动id」调用。
|
||||
*
|
||||
* 查询接口(``action_history_url``)地址**目前待提供**:为空时直接返回
|
||||
* ``{ exists: false, known: false }``——视作空白、不查询,也不阻断删除;后端配上真实地址后自动生效。
|
||||
*
|
||||
* ⚠️ 真实返回结构待定:拿到接口后按实际字段调整「是否存在」的判定(当前暂按 ``data`` 非空兜底)。
|
||||
*/
|
||||
export async function fetchActionHistoryExists(actionId: string): Promise<ActionHistoryResult> {
|
||||
if (!actionId) return { exists: false, known: false };
|
||||
const cfg = await fetchTaskDeeplinkConfig();
|
||||
if (!cfg.actionHistoryUrl) return { exists: false, known: false };
|
||||
try {
|
||||
const body = await requestCrudService.get(cfg.actionHistoryUrl, { actionId });
|
||||
// TODO(接口就绪):按真实返回结构判定。暂按整包 body.data 非空作「存在历史」。
|
||||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||||
const exists = Array.isArray(data) ? data.length > 0 : data != null && data !== "";
|
||||
return { exists, known: true };
|
||||
} catch {
|
||||
return { exists: false, known: false };
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------- 跳转地址 ----------------------------- */
|
||||
|
||||
/** B 系统「树形完整编辑」页地址(问答后「编辑」按钮,新标签打开)。用同步缓存配置。 */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import type {
|
||||
BatchIngestPayload,
|
||||
@@ -41,9 +42,17 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
|
||||
if (params.folder !== undefined) qs.set("folder", params.folder);
|
||||
qs.set("limit", String(params.limit ?? 20));
|
||||
qs.set("offset", String(params.offset ?? 0));
|
||||
return parseResponse<KnowledgeListResponse>(
|
||||
const result = await parseResponse<KnowledgeListResponse>(
|
||||
await apiFetch(`${baseUrl()}/notes?${qs.toString()}`),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-知识库-检索",
|
||||
url: `${baseUrl()}/notes?${qs.toString()}`,
|
||||
reqParam: params,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getKnowledgeNote(noteId: string) {
|
||||
@@ -67,6 +76,13 @@ export async function createKnowledgeNote(data: {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "B1-CM助手-知识库-新建",
|
||||
url: `${baseUrl()}/notes`,
|
||||
reqParam: data,
|
||||
returnParam: res.note,
|
||||
});
|
||||
return res.note;
|
||||
}
|
||||
|
||||
@@ -81,22 +97,38 @@ export async function updateKnowledgeNote(
|
||||
folder: string | null;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<KnowledgeNote>(
|
||||
const result = await parseResponse<KnowledgeNote>(
|
||||
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-知识库-修改",
|
||||
url: `${baseUrl()}/notes/${encodeURIComponent(noteId)}`,
|
||||
reqParam: { noteId, data },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeNote(noteId: string, hard = false) {
|
||||
return parseResponse<{ ok: boolean; note_id: string }>(
|
||||
const result = await parseResponse<{ ok: boolean; note_id: string }>(
|
||||
await apiFetch(
|
||||
`${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "B1-CM助手-知识库-删除",
|
||||
url: `${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`,
|
||||
reqParam: { noteId, hard },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function searchKnowledge(query: string, limit = 8) {
|
||||
@@ -107,11 +139,18 @@ export async function searchKnowledge(query: string, limit = 8) {
|
||||
body: JSON.stringify({ query, mode: "keyword", limit }),
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-知识库-检索",
|
||||
url: `${baseUrl()}/search`,
|
||||
reqParam: { query, mode: "keyword", limit },
|
||||
returnParam: res.items,
|
||||
});
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) {
|
||||
return parseResponse<{
|
||||
const result = await parseResponse<{
|
||||
note?: KnowledgeNote;
|
||||
created?: boolean;
|
||||
queued?: boolean;
|
||||
@@ -122,16 +161,32 @@ export async function ingestThread(threadId: string, payload: IngestThreadPayloa
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.import,
|
||||
optDetail: "B1-CM助手-知识库-导入",
|
||||
url: `${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`,
|
||||
reqParam: { threadId, payload },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
|
||||
return parseResponse<BatchIngestResult>(
|
||||
const result = await parseResponse<BatchIngestResult>(
|
||||
await apiFetch(`${baseUrl()}/ingest/threads/batch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.import,
|
||||
optDetail: "B1-CM助手-知识库-导入",
|
||||
url: `${baseUrl()}/ingest/threads/batch`,
|
||||
reqParam: payload,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface IngestFileResult {
|
||||
@@ -162,9 +217,17 @@ export async function ingestKnowledgeFile(
|
||||
if (opts.template_id) form.append("template_id", opts.template_id);
|
||||
if (opts.folder) form.append("folder", opts.folder);
|
||||
// No Content-Type header — the browser sets the multipart boundary itself.
|
||||
return parseResponse<IngestFileResult>(
|
||||
const result = await parseResponse<IngestFileResult>(
|
||||
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.import,
|
||||
optDetail: "B1-CM助手-知识库-导入",
|
||||
url: `${baseUrl()}/ingest/file`,
|
||||
reqParam: { fileName: file.name, ...opts },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
|
||||
@@ -197,9 +260,16 @@ export async function fetchVaultPage(path: string) {
|
||||
}
|
||||
|
||||
export async function exportKnowledgeGraph() {
|
||||
return parseResponse<GraphExportResult>(
|
||||
const result = await parseResponse<GraphExportResult>(
|
||||
await apiFetch(`${baseUrl()}/export/graph`, { method: "POST" }),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.export,
|
||||
optDetail: "B1-CM助手-知识库-导出",
|
||||
url: `${baseUrl()}/export/graph`,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Absolute URL of a served wiki-export file (for iframe / download). */
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 操作日志(Y-Log)上报的运行时配置:从后端公开只读接口
|
||||
* ``GET /api/public/config/y-log`` 读取(地址 + 开关在后端 ``config.yaml`` 的
|
||||
* ``y_log`` 段,改完即时生效,前端无需重建)。
|
||||
*
|
||||
* 仿 [[task-deeplink-config]]:
|
||||
* - ``fetchYLogConfig()`` 拉取并模块级缓存(启动早期预热一次即可)。
|
||||
* - ``getCachedYLogConfig()`` 同步读缓存(``writeYLog`` 即发即忘,必须同步可用)。
|
||||
*
|
||||
* 拉取失败 → 用内置默认值兜底(``enabled:false``,但保留默认地址),绝不抛错。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface YLogConfig {
|
||||
/** 是否启用上报。 */
|
||||
enabled: boolean;
|
||||
/** 上报外部接口完整地址(POST)。 */
|
||||
writeUrl: string;
|
||||
}
|
||||
|
||||
/** 内置默认地址(后端拿不到 / 未配置时的兜底地址;enabled 默认 false 不上报)。 */
|
||||
const DEFAULT_WRITE_URL = "https://ch1.b.uat.4.cn/api/y-log/write";
|
||||
|
||||
/** 内置默认配置(拉取失败兜底;默认不上报)。 */
|
||||
export const DEFAULT_Y_LOG_CONFIG: YLogConfig = {
|
||||
enabled: false,
|
||||
writeUrl: DEFAULT_WRITE_URL,
|
||||
};
|
||||
|
||||
/** 后端 snake_case 响应 → 前端 camelCase;缺字段回退默认值。 */
|
||||
function normalize(body: unknown): YLogConfig {
|
||||
const o = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||
const writeUrl =
|
||||
typeof o.write_url === "string" && o.write_url.trim() ? o.write_url.trim() : DEFAULT_WRITE_URL;
|
||||
return {
|
||||
enabled: o.enabled === true,
|
||||
writeUrl,
|
||||
};
|
||||
}
|
||||
|
||||
let cached: YLogConfig | null = null;
|
||||
let inflight: Promise<YLogConfig> | null = null;
|
||||
|
||||
/** 拉取(带模块级缓存 + 并发去重);失败回退默认值。 */
|
||||
export async function fetchYLogConfig(): Promise<YLogConfig> {
|
||||
if (cached) return cached;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/public/config/y-log`);
|
||||
if (!res.ok) throw new Error(`y-log config ${res.status}`);
|
||||
cached = normalize(await res.json());
|
||||
} catch {
|
||||
cached = { ...DEFAULT_Y_LOG_CONFIG };
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
return cached;
|
||||
})();
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** 同步读缓存(未拉取过则返回默认值,默认 enabled:false 不上报)。 */
|
||||
export function getCachedYLogConfig(): YLogConfig {
|
||||
return cached ?? DEFAULT_Y_LOG_CONFIG;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* token1(token 登录用的上游 token,即 URL 参数 ``?authToken=``)的存取。
|
||||
*
|
||||
* 复用 [[scoped-storage]] 的 ``authStorage*``:嵌入(iframe)态自动落 sessionStorage、
|
||||
* 正常态落 localStorage,使 iframe 里登录的用户 token1 与正常标签页**完全隔离**、
|
||||
* 互不污染(与其它鉴权键同一隔离语义)。
|
||||
*
|
||||
* 只有用 token 登录(存在 token1)才上报操作日志;非 token 登录不写 token1,
|
||||
* 该会话从此不上报(见 [[y-log]])。
|
||||
*/
|
||||
|
||||
import {
|
||||
authStorageGet,
|
||||
authStorageRemove,
|
||||
authStorageSet,
|
||||
} from "@/core/auth/scoped-storage";
|
||||
|
||||
const Y_LOG_TOKEN_KEY = "ylog.token1";
|
||||
|
||||
/** 持久化 token1(token 登录成功后调用;空值忽略)。 */
|
||||
export function persistYLogToken(token: string | null | undefined): void {
|
||||
const t = token?.trim();
|
||||
if (t) authStorageSet(Y_LOG_TOKEN_KEY, t);
|
||||
}
|
||||
|
||||
/** 读 token1(无则返回 null → 不上报)。 */
|
||||
export function readYLogToken(): string | null {
|
||||
return authStorageGet(Y_LOG_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/** 清除 token1(登出时调用)。 */
|
||||
export function clearYLogToken(): void {
|
||||
authStorageRemove(Y_LOG_TOKEN_KEY);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 操作日志(Y-Log)上报主函数。
|
||||
*
|
||||
* 把用户的关键操作(点击/查询/查看/增删改/启停/导入导出/登录登出/问答等)上报到
|
||||
* 外部日志接口(见 [[y-log-config]] 的 ``writeUrl``)。**前端直连**该接口,不经后端代理。
|
||||
*
|
||||
* 硬性约束(务必满足):
|
||||
* ① **token 门控**:无 token1(非 token 登录)→ 直接 return,不发请求。
|
||||
* ② **配置门控**:``enabled=false`` 或 ``writeUrl`` 为空 → 不发请求。
|
||||
* ③ **异步即发即忘**:``void fetch(...)`` 不被 ``await``,绝不阻塞业务/UI。
|
||||
* ④ **永不抛错**:整体包在 ``try/catch`` 中,任何异常(取 token/读配置/序列化/网络)静默吞掉。
|
||||
* ⑤ **只报成功**:埋点只放在操作成功路径,``optResult`` 恒为 ``0``。
|
||||
* ⑥ **失败静默**:``fetch`` 的 ``.catch`` 吞掉网络/跨域失败。
|
||||
*/
|
||||
|
||||
import { getCachedYLogConfig } from "@/core/log/y-log-config";
|
||||
import { readYLogToken } from "@/core/log/y-log-token";
|
||||
|
||||
/** 操作类型,数值与外部系统约定一致,不可改。 */
|
||||
export enum OptType {
|
||||
login = 0,
|
||||
logout = 1,
|
||||
search = 3,
|
||||
view = 4,
|
||||
edit = 5,
|
||||
delete = 6,
|
||||
import = 7,
|
||||
export = 8,
|
||||
create = 9,
|
||||
disabled = 10,
|
||||
enabled = 11,
|
||||
other = 12,
|
||||
}
|
||||
|
||||
export interface WriteYLogParams {
|
||||
/** 操作类型枚举。 */
|
||||
optType: OptType;
|
||||
/** 操作详情(简短描述),统一「B1-CM助手-xxx」。 */
|
||||
optDetail: string;
|
||||
/** 操作命中的接口地址(完整或相对,内部自动转相对,去 ip/host)。 */
|
||||
url: string;
|
||||
/** 入参(对象自动 JSON 序列化)。 */
|
||||
reqParam?: unknown;
|
||||
/** 返回参数(对象自动 JSON 序列化)。 */
|
||||
returnParam?: unknown;
|
||||
/** 登出 / 页面卸载等场景置 true,保证请求在卸载时仍能发出。 */
|
||||
keepalive?: boolean;
|
||||
}
|
||||
|
||||
/** 入参/返回体积上限,超出裁剪。 */
|
||||
const MAX_PARAM_LEN = 50_000;
|
||||
|
||||
/** 上报一条操作日志(异步即发即忘,永不抛错)。 */
|
||||
export function writeYLog(params: WriteYLogParams): void {
|
||||
try {
|
||||
const token = readYLogToken();
|
||||
if (!token) return; // ① 无 token1 不上报
|
||||
const { enabled, writeUrl } = getCachedYLogConfig();
|
||||
if (!enabled || !writeUrl) return; // ② 配置门控
|
||||
if (typeof fetch === "undefined") return;
|
||||
|
||||
const body = {
|
||||
optType: params.optType,
|
||||
optDetail: params.optDetail ?? "",
|
||||
url: toRelativeUrl(params.url ?? ""), // ③ 去 ip/host
|
||||
reqParam: toParamString(params.reqParam), // ④ 对象→字符串+裁剪
|
||||
returnParam: toParamString(params.returnParam),
|
||||
optResult: 0, // ⑤ 恒为 0(只报成功)
|
||||
};
|
||||
|
||||
void fetch(writeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
keepalive: params.keepalive ?? false,
|
||||
}).catch(() => undefined); // ⑥ 失败静默
|
||||
} catch {
|
||||
/* 绝不影响业务 */
|
||||
}
|
||||
}
|
||||
|
||||
function toParamString(v: unknown): string {
|
||||
if (v == null) return "";
|
||||
const s = typeof v === "string" ? v : safeStringify(v);
|
||||
return s.length > MAX_PARAM_LEN ? `${s.slice(0, MAX_PARAM_LEN)}…[truncated]` : s;
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v) ?? "";
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉 ip/host,只留 path + query。 */
|
||||
function toRelativeUrl(url: string): string {
|
||||
if (!url) return "";
|
||||
try {
|
||||
const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
||||
const u = new URL(url, base);
|
||||
return `${u.pathname}${u.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import type {
|
||||
HtmlPageFavorite,
|
||||
@@ -51,9 +52,17 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
|
||||
if (params.search) qs.set("search", params.search);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
return parseResponse<ScheduledTask[]>(
|
||||
const result = await parseResponse<ScheduledTask[]>(
|
||||
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-定时任务-列表",
|
||||
url: `${baseUrl()}${query ? `?${query}` : ""}`,
|
||||
reqParam: params,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createScheduledTask(data: {
|
||||
@@ -66,13 +75,21 @@ export async function createScheduledTask(data: {
|
||||
require_markdown?: boolean;
|
||||
execution_context?: ScheduledTaskExecutionContext | null;
|
||||
}) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
const result = await parseResponse<ScheduledTask>(
|
||||
await apiFetch(baseUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "B1-CM助手-定时任务-新建",
|
||||
url: baseUrl(),
|
||||
reqParam: data,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listScheduledTaskRuns(taskId: string) {
|
||||
@@ -82,9 +99,17 @@ export async function listScheduledTaskRuns(taskId: string) {
|
||||
}
|
||||
|
||||
export async function getScheduledTaskRun(runId: string) {
|
||||
return parseResponse<ScheduledTaskRun>(
|
||||
const result = await parseResponse<ScheduledTaskRun>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.view,
|
||||
optDetail: "B1-CM助手-定时任务-运行详情",
|
||||
url: `${baseUrl()}/runs/${encodeURIComponent(runId)}`,
|
||||
reqParam: { runId },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteScheduledTaskRun(runId: string) {
|
||||
@@ -228,45 +253,85 @@ export async function updateScheduledTask(
|
||||
execution_context: ScheduledTaskExecutionContext | null;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
const result = await parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-定时任务-修改",
|
||||
url: `${baseUrl()}/${encodeURIComponent(taskId)}`,
|
||||
reqParam: { taskId, data },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean }>(
|
||||
const result = await parseResponse<{ success: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "B1-CM助手-定时任务-删除",
|
||||
url: `${baseUrl()}/${encodeURIComponent(taskId)}`,
|
||||
reqParam: { taskId },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function pauseScheduledTask(taskId: string) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
const result = await parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/pause`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.disabled,
|
||||
optDetail: "B1-CM助手-定时任务-暂停",
|
||||
url: `${baseUrl()}/${encodeURIComponent(taskId)}/pause`,
|
||||
reqParam: { taskId },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resumeScheduledTask(taskId: string) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
const result = await parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/resume`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.enabled,
|
||||
optDetail: "B1-CM助手-定时任务-恢复",
|
||||
url: `${baseUrl()}/${encodeURIComponent(taskId)}/resume`,
|
||||
reqParam: { taskId },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function triggerScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean; task: ScheduledTask }>(
|
||||
const result = await parseResponse<{ success: boolean; task: ScheduledTask }>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/trigger`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.other,
|
||||
optDetail: "B1-CM助手-定时任务-手动运行",
|
||||
url: `${baseUrl()}/${encodeURIComponent(taskId)}/trigger`,
|
||||
reqParam: { taskId },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listPublishedScheduledTasks(params: { limit?: number; offset?: number } = {}) {
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
type IRunOptions,
|
||||
} from "docx";
|
||||
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
const BLACK = "000000";
|
||||
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
|
||||
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
|
||||
@@ -484,6 +486,12 @@ export async function downloadMarkdownAsDocx(
|
||||
title?: string,
|
||||
): Promise<void> {
|
||||
const blob = await markdownToDocxBlob(markdown, title);
|
||||
writeYLog({
|
||||
optType: OptType.export,
|
||||
optDetail: "B1-CM助手-定时任务-导出",
|
||||
url: "/scheduled-tasks/export/docx",
|
||||
reqParam: { fileName, title },
|
||||
});
|
||||
const safeName = fileName.replace(/\.(md|markdown)$/i, "") || "document";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useNotification } from "@/core/notification/hooks";
|
||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { env } from "@/env";
|
||||
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
|
||||
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||||
@@ -157,6 +158,21 @@ export default function AgentChatPage({
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
// 操作日志(Y-Log):专家代理问答完成(成功)后,用最终汇总文本上报一条。
|
||||
try {
|
||||
const msgs = state.messages ?? [];
|
||||
const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai");
|
||||
const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human");
|
||||
writeYLog({
|
||||
optType: OptType.other,
|
||||
optDetail: "B1-CM助手-专家代理问答",
|
||||
url: `/api/threads/${threadId ?? ""}/runs/stream`,
|
||||
reqParam: { agent_id, question: lastHuman ? textOfMessage(lastHuman) : "" },
|
||||
returnParam: { data: lastAi ? textOfMessage(lastAi) : "" },
|
||||
});
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -187,6 +188,21 @@ export default function ChatPage() {
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
// 操作日志(Y-Log):问答完成(成功)后,用最终汇总文本上报一条。
|
||||
try {
|
||||
const msgs = state.messages ?? [];
|
||||
const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai");
|
||||
const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human");
|
||||
writeYLog({
|
||||
optType: OptType.other,
|
||||
optDetail: "B1-CM助手-专家代理问答",
|
||||
url: `/api/threads/${threadId ?? ""}/runs/stream`,
|
||||
reqParam: { agent_id: selectedAgentId, question: lastHuman ? textOfMessage(lastHuman) : "" },
|
||||
returnParam: { data: lastAi ? textOfMessage(lastAi) : "" },
|
||||
});
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
BrainCircuitIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
ShrinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
type BrainyChatSubmitMessage,
|
||||
parseBrainyChatSubmitMessage,
|
||||
} from "./iframe-chat-submit";
|
||||
import { resolveIframeAssistantId } from "./iframe-chat-agent";
|
||||
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
|
||||
|
||||
type BrainyChatStatus = "started" | "finished";
|
||||
type BrainyAuthTokenMessage = {
|
||||
@@ -93,7 +95,6 @@ function postBrainyAuthToken(accessToken: string) {
|
||||
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -152,11 +153,19 @@ export default function IframeChatPage() {
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const activeAssistantId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId: null,
|
||||
});
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
context: {
|
||||
...settings.context,
|
||||
...(activeAssistantId ? { agent_id: activeAssistantId } : {}),
|
||||
},
|
||||
isMock,
|
||||
assistantId: activeAssistantId,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
@@ -193,6 +202,22 @@ export default function IframeChatPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const threadMetadataAgentId = (() => {
|
||||
const metadata = (thread as { metadata?: Record<string, unknown> | null })
|
||||
.metadata;
|
||||
return typeof metadata?.agent_id === "string" ? metadata.agent_id : null;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedAgentId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
});
|
||||
if (resolvedAgentId && resolvedAgentId !== selectedAgentId) {
|
||||
setSelectedAgentId(resolvedAgentId);
|
||||
}
|
||||
}, [selectedAgentId, threadMetadataAgentId]);
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
||||
@@ -210,31 +235,15 @@ export default function IframeChatPage() {
|
||||
externalContext?: Record<string, unknown>,
|
||||
options?: { additionalKwargs?: Record<string, unknown> },
|
||||
) => {
|
||||
if (
|
||||
selectedAgentId &&
|
||||
isNewThread &&
|
||||
!hasStartedConversation &&
|
||||
message.text &&
|
||||
(!message.files || message.files.length === 0)
|
||||
) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"pendingAgentMessage",
|
||||
JSON.stringify({ agentId: selectedAgentId, text: message.text }),
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage may be unavailable in private mode; fall through.
|
||||
}
|
||||
router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
|
||||
const extraContext: Record<string, unknown> = {};
|
||||
if (activeAssistantId) {
|
||||
extraContext.agent_id = activeAssistantId;
|
||||
}
|
||||
if (settings.context.writing_mode && writingArtifactPath) {
|
||||
extraContext.writing_artifact_path = writingArtifactPath;
|
||||
}
|
||||
@@ -250,10 +259,8 @@ export default function IframeChatPage() {
|
||||
);
|
||||
},
|
||||
[
|
||||
activeAssistantId,
|
||||
hasStartedConversation,
|
||||
isNewThread,
|
||||
router,
|
||||
selectedAgentId,
|
||||
sendMessage,
|
||||
settings.context.writing_mode,
|
||||
threadId,
|
||||
@@ -277,6 +284,8 @@ export default function IframeChatPage() {
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const contextCompressionEnabled =
|
||||
localSettings.display.contextCompressionEnabled ?? false;
|
||||
const proModeEnabled = settings.context.mode === "pro";
|
||||
|
||||
const toggleWritingMode = useCallback(() => {
|
||||
@@ -301,6 +310,12 @@ export default function IframeChatPage() {
|
||||
});
|
||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||
|
||||
const toggleContextCompression = useCallback(() => {
|
||||
setLocalSettings("display", {
|
||||
contextCompressionEnabled: !contextCompressionEnabled,
|
||||
});
|
||||
}, [contextCompressionEnabled, setLocalSettings]);
|
||||
|
||||
const toggleProMode = useCallback(() => {
|
||||
setSettings("context", {
|
||||
mode: proModeEnabled ? "flash" : "pro",
|
||||
@@ -333,28 +348,29 @@ export default function IframeChatPage() {
|
||||
const data = event.data;
|
||||
if (data?.source !== "brainy-extension") return;
|
||||
if (data?.type === "brainy:chat-interrupt") {
|
||||
void handleInterrupt(data);
|
||||
void handleInterrupt(data as BrainyChatInterruptMessage);
|
||||
return;
|
||||
}
|
||||
if (data?.type !== "brainy:chat-submit") return;
|
||||
const submitMessage = data as BrainyChatSubmitMessage;
|
||||
|
||||
const { submitText, files, iframeContext, additionalKwargs } =
|
||||
parseBrainyChatSubmitMessage(data);
|
||||
parseBrainyChatSubmitMessage(submitMessage);
|
||||
|
||||
console.log("收到插件消息", {
|
||||
text: data.payload?.text,
|
||||
prompt: data.payload?.prompt,
|
||||
pageContext: data.payload?.pageContext,
|
||||
screenshot: data.payload?.screenshot,
|
||||
model: data.payload?.model,
|
||||
requestId: data.requestId,
|
||||
text: submitMessage.payload?.text,
|
||||
prompt: submitMessage.payload?.prompt,
|
||||
pageContext: submitMessage.payload?.pageContext,
|
||||
screenshot: submitMessage.payload?.screenshot,
|
||||
model: submitMessage.payload?.model,
|
||||
requestId: submitMessage.requestId,
|
||||
filesCount: files.length,
|
||||
iframeContext,
|
||||
});
|
||||
|
||||
if (!submitText && files.length === 0) return;
|
||||
|
||||
pendingRequestIdRef.current = data.requestId ?? null;
|
||||
pendingRequestIdRef.current = submitMessage.requestId ?? null;
|
||||
|
||||
handleSubmit(
|
||||
{ text: submitText, files },
|
||||
@@ -371,41 +387,59 @@ export default function IframeChatPage() {
|
||||
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
const bottomBarHeightClass = "pb-24";
|
||||
const modeButtons = [
|
||||
{
|
||||
key: "writing",
|
||||
label: "写作",
|
||||
icon: FileTextIcon,
|
||||
active: !!settings.context.writing_mode,
|
||||
onClick: toggleWritingMode,
|
||||
activeClassName: "text-amber-600 hover:text-amber-700",
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: "记忆",
|
||||
icon: BrainCircuitIcon,
|
||||
active: settings.context.memory_injection_enabled ?? true,
|
||||
onClick: toggleMemoryInjection,
|
||||
activeClassName: "text-blue-600 hover:text-blue-700",
|
||||
},
|
||||
{
|
||||
key: "references",
|
||||
label: "参考文献",
|
||||
icon: BookMarkedIcon,
|
||||
active: ragModeEnabled,
|
||||
onClick: toggleRagMode,
|
||||
disabled: ragModeLocked,
|
||||
activeClassName: "text-emerald-600 hover:text-emerald-700",
|
||||
},
|
||||
{
|
||||
key: "pro",
|
||||
label: "专业问答",
|
||||
icon: GraduationCapIcon,
|
||||
active: proModeEnabled,
|
||||
onClick: toggleProMode,
|
||||
activeClassName: "text-violet-600 hover:text-violet-700",
|
||||
},
|
||||
] as const;
|
||||
const modeButtons = buildIframeModeButtons({
|
||||
agentSelected: !!selectedAgentId,
|
||||
writingModeEnabled: !!settings.context.writing_mode,
|
||||
memoryInjectionEnabled: Boolean(
|
||||
settings.context.memory_injection_enabled ?? true,
|
||||
),
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
contextCompressionEnabled,
|
||||
proModeEnabled,
|
||||
}).map((button) => {
|
||||
switch (button.key) {
|
||||
case "agent":
|
||||
return button;
|
||||
case "writing":
|
||||
return {
|
||||
...button,
|
||||
icon: FileTextIcon,
|
||||
onClick: toggleWritingMode,
|
||||
activeClassName: "text-amber-600 hover:text-amber-700",
|
||||
};
|
||||
case "memory":
|
||||
return {
|
||||
...button,
|
||||
icon: BrainCircuitIcon,
|
||||
onClick: toggleMemoryInjection,
|
||||
activeClassName: "text-blue-600 hover:text-blue-700",
|
||||
};
|
||||
case "references":
|
||||
return {
|
||||
...button,
|
||||
icon: BookMarkedIcon,
|
||||
onClick: toggleRagMode,
|
||||
activeClassName: "text-emerald-600 hover:text-emerald-700",
|
||||
};
|
||||
case "contextCompression":
|
||||
return {
|
||||
...button,
|
||||
icon: ShrinkIcon,
|
||||
onClick: toggleContextCompression,
|
||||
activeClassName: "text-violet-600 hover:text-violet-700",
|
||||
};
|
||||
case "pro":
|
||||
return {
|
||||
...button,
|
||||
icon: GraduationCapIcon,
|
||||
onClick: toggleProMode,
|
||||
activeClassName: "text-fuchsia-600 hover:text-fuchsia-700",
|
||||
};
|
||||
default:
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
@@ -496,6 +530,19 @@ export default function IframeChatPage() {
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-0 px-0 py-1">
|
||||
{modeButtons.map((button) => {
|
||||
if (button.key === "agent") {
|
||||
return (
|
||||
<AgentSelector
|
||||
key={button.key}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
disabled={isUploading}
|
||||
triggerVariant="icon"
|
||||
iconButtonClassName="size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground"
|
||||
iconActiveClassName="text-amber-600 hover:text-amber-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Icon = button.icon;
|
||||
return (
|
||||
<Tooltip key={button.key} content={button.label}>
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
isSidebarEnabledFromSearch,
|
||||
persistSidebarEnabled,
|
||||
} from '@/core/page-layout/sidebar-enabled';
|
||||
import { fetchYLogConfig } from '@/core/log/y-log-config';
|
||||
import { persistYLogToken } from '@/core/log/y-log-token';
|
||||
import { OptType, writeYLog } from '@/core/log/y-log';
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_LOGIN_SOURCE = 'iframe';
|
||||
@@ -160,6 +163,19 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
// 操作日志(Y-Log):只有 token 登录(存在 token1=authToken)才上报。
|
||||
// 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。
|
||||
if (authToken) {
|
||||
persistYLogToken(authToken);
|
||||
void fetchYLogConfig();
|
||||
writeYLog({
|
||||
optType: OptType.login,
|
||||
optDetail: 'B1-CM助手-登录',
|
||||
url: '/api/v1/auth/login/token',
|
||||
reqParam: { username: loginUserName || auth.user_id },
|
||||
returnParam: { user_id: auth.user_id },
|
||||
});
|
||||
}
|
||||
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
|
||||
setStoredUserName(loginUserName);
|
||||
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import { getNotebookApiBaseURL } from '@/core/config';
|
||||
import { useTheme, type ThemeType } from '@/contexts/ThemeContext';
|
||||
|
||||
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue'];
|
||||
|
||||
interface NotebookTokenData {
|
||||
doc_ids: number[];
|
||||
@@ -13,6 +16,17 @@ interface NotebookTokenData {
|
||||
export default function NotebookAIWritingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const themekey = searchParams.get('themekey');
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
// 跟随调用方传入的 themekey 切换主题;缺省或非法值兜底为 light
|
||||
useEffect(() => {
|
||||
const next = themekey && VALID_THEMES.includes(themekey as ThemeType)
|
||||
? (themekey as ThemeType)
|
||||
: 'light';
|
||||
setTheme(next);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themekey]);
|
||||
|
||||
const [data, setData] = useState<NotebookTokenData | null>(null);
|
||||
const [spaceName, setSpaceName] = useState<string | null>(null);
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
AlertCircle,
|
||||
AlignLeft,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
BookOpen,
|
||||
Files,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -27,6 +29,7 @@ import { fetchDocumentByChunk } from "@/core/studio/api";
|
||||
import type { DocumentWithChunks } from "@/core/studio/api";
|
||||
import type { SourceItem } from "@/core/studio/types";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
Select,
|
||||
@@ -118,6 +121,20 @@ function SourceCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ── 上传中占位卡片 ──────────────────────────────────────
|
||||
function PendingSourceCard({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="group relative flex h-32 w-full flex-col justify-between rounded-2xl p-4 ring-1 ring-border/50 bg-muted/40 cursor-default">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-foreground">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 来源骨架屏 ──────────────────────────────────────────
|
||||
function SourceCardSkeleton() {
|
||||
return (
|
||||
@@ -136,6 +153,7 @@ function SourcesDialog({
|
||||
sources,
|
||||
isLoadingSources,
|
||||
selectedSourceIds,
|
||||
pendingSources,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
@@ -145,6 +163,7 @@ function SourcesDialog({
|
||||
sources: SourceItem[];
|
||||
isLoadingSources: boolean;
|
||||
selectedSourceIds: number[];
|
||||
pendingSources: Map<string, { id?: number; title: string }>;
|
||||
onToggle: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}) {
|
||||
@@ -188,15 +207,26 @@ function SourcesDialog({
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{isLoadingSources
|
||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||
: sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
: <>
|
||||
{Array.from(pendingSources.entries())
|
||||
.filter(([, s]) => !sources.some((src) => {
|
||||
const srcTitle = src.title ?? (src.document as { title?: string } | undefined)?.title ?? "";
|
||||
return srcTitle === s.title;
|
||||
}))
|
||||
.map(([taskId, s]) => (
|
||||
<PendingSourceCard key={taskId} title={s.title} />
|
||||
))}
|
||||
{sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
{!isLoadingSources && sources.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||
@@ -218,6 +248,7 @@ export default function StudioNotebookDetailPage({
|
||||
const params = useParams<{ notebookId: string }>();
|
||||
const notebookId =
|
||||
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
|
||||
const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key
|
||||
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
|
||||
const { models } = useModels();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -229,6 +260,8 @@ export default function StudioNotebookDetailPage({
|
||||
// null = 显示历史线程列表;string = 显示该线程的聊天
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
||||
// 轮询成功后待选中的来源标题集合(后端上传时不返回 id,等 sources 刷新后按标题匹配)
|
||||
const pendingSelectTitlesRef = useRef<Set<string>>(new Set());
|
||||
const [threadsExpanded, setThreadsExpanded] = useState(false);
|
||||
const THREADS_LIMIT = 5;
|
||||
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
||||
@@ -253,6 +286,22 @@ export default function StudioNotebookDetailPage({
|
||||
setSelectedSourceIds(sources.map((s) => s.id));
|
||||
}
|
||||
}, [sources, notebook?.id, initializedForNotebook]);
|
||||
|
||||
// sources 刷新后,将轮询成功的来源(按标题匹配)自动选中
|
||||
useEffect(() => {
|
||||
if (pendingSelectTitlesRef.current.size === 0) return;
|
||||
const matched = sources.filter((s) => {
|
||||
const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? "";
|
||||
return pendingSelectTitlesRef.current.has(title);
|
||||
});
|
||||
if (matched.length > 0) {
|
||||
setSelectedSourceIds((prev) => [...new Set([...prev, ...matched.map((s) => s.id)])]);
|
||||
matched.forEach((s) => {
|
||||
const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? "";
|
||||
pendingSelectTitlesRef.current.delete(title);
|
||||
});
|
||||
}
|
||||
}, [sources]);
|
||||
const citationRehypePlugins = useMemo(() => {
|
||||
const mapped = sources.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -297,6 +346,10 @@ export default function StudioNotebookDetailPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
void thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const handleSend = async (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
@@ -358,20 +411,17 @@ export default function StudioNotebookDetailPage({
|
||||
const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' };
|
||||
const taskStatus = data.status === 'failed' ? 'error' : data.status;
|
||||
if (taskStatus !== 'processing') {
|
||||
// 任务完成,从轮询队列移除并刷新来源列表
|
||||
// 先取出 source 信息,再从轮询队列移除
|
||||
const source = pendingSources.get(taskId);
|
||||
setPendingSources((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const source = newMap.get(taskId);
|
||||
newMap.delete(taskId);
|
||||
if (source) {
|
||||
if (taskStatus === 'success') {
|
||||
// toast.success(`来源 "${source.title}" 处理完成`);
|
||||
} else {
|
||||
// toast.error(`来源 "${source.title}" 处理失败`);
|
||||
}
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
// 仅成功时记录标题,等 sources 刷新后按标题匹配 id 并选中
|
||||
if (taskStatus === 'success' && source?.title) {
|
||||
pendingSelectTitlesRef.current.add(source.title);
|
||||
}
|
||||
// 刷新来源列表
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||
// 刷新 notebooks 数据以更新 source_count
|
||||
@@ -381,7 +431,7 @@ export default function StudioNotebookDetailPage({
|
||||
} catch (error) {
|
||||
console.error(`轮询任务 ${taskId} 状态失败:`, error);
|
||||
}
|
||||
}, [notebook?.id, queryClient]);
|
||||
}, [notebook?.id, pendingSources, queryClient]);
|
||||
|
||||
// 控制轮询 - 只在对话框打开时轮询
|
||||
useEffect(() => {
|
||||
@@ -508,7 +558,7 @@ export default function StudioNotebookDetailPage({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
@@ -532,7 +582,7 @@ export default function StudioNotebookDetailPage({
|
||||
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{/* 右上角跳转按钮 */}
|
||||
<a
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
@@ -583,7 +633,6 @@ export default function StudioNotebookDetailPage({
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
{models.length > 0 && (
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||
@@ -598,6 +647,24 @@ export default function StudioNotebookDetailPage({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
title="停止回答"
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</button>
|
||||
) : input.trim() ? (
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="发送"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
@@ -711,7 +778,6 @@ export default function StudioNotebookDetailPage({
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
{models.length > 0 && (
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||
@@ -726,6 +792,24 @@ export default function StudioNotebookDetailPage({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
title="停止回答"
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</button>
|
||||
) : input.trim() ? (
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="发送"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -769,6 +853,7 @@ export default function StudioNotebookDetailPage({
|
||||
sources={sources}
|
||||
isLoadingSources={isLoadingSources}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
pendingSources={pendingSources}
|
||||
onToggle={handleToggleSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import type { StudioNotebook } from "@/core/studio/types";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,13 +24,16 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function NotebookCardSkeleton() {
|
||||
function NotebookCardSkeleton({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}>
|
||||
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" />
|
||||
<div
|
||||
className={`flex flex-col rounded-[20px] p-5 ${isDark ? "bg-[#1e2330]" : "bg-[#f2f2f2]"}`}
|
||||
style={{ minHeight: 180 }}
|
||||
>
|
||||
<div className={`size-10 animate-pulse rounded-xl ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-8">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" />
|
||||
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" />
|
||||
<div className={`h-4 w-3/4 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
|
||||
<div className={`h-3 w-1/3 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -37,11 +41,13 @@ function NotebookCardSkeleton() {
|
||||
|
||||
function NotebookCard({
|
||||
nb,
|
||||
isDark,
|
||||
onRename,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
nb: StudioNotebook;
|
||||
isDark: boolean;
|
||||
onRename: (nb: StudioNotebook) => void;
|
||||
onDelete: (nb: StudioNotebook) => void;
|
||||
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
|
||||
@@ -61,16 +67,16 @@ function NotebookCard({
|
||||
<div className="size-7" />
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-1 pt-8">
|
||||
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]">
|
||||
<span className={`line-clamp-2 text-[15px] font-medium leading-snug ${isDark ? "text-[#e8eaf0]" : "text-[#1f1f1f]"}`}>
|
||||
{nb.name}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
|
||||
<span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
|
||||
<div className={`group relative flex flex-col rounded-[20px] transition-colors ${isDark ? "bg-[#1e2330] hover:bg-[#262c3b]" : "bg-[#f2f2f2] hover:bg-[#e8e8e8]"}`} style={{ minHeight: 180 }}>
|
||||
{onOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -125,6 +131,8 @@ export default function StudioNotebooksPage({
|
||||
onOpenNotebook?: (id: number) => void;
|
||||
} = {}) {
|
||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ── 新建 ──
|
||||
@@ -221,11 +229,12 @@ export default function StudioNotebooksPage({
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
|
||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} isDark={isDark} />)
|
||||
: notebooks.map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
isDark={isDark}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
onOpen={onOpenNotebook}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-ignore Node's test runner resolves the local .ts module directly.
|
||||
import { resolveIframeAssistantId } from "./iframe-chat-agent.ts";
|
||||
|
||||
test("prefers the currently selected iframe agent", () => {
|
||||
assert.equal(
|
||||
resolveIframeAssistantId({
|
||||
selectedAgentId: "roundtable-structure",
|
||||
threadMetadataAgentId: "lead_agent",
|
||||
}),
|
||||
"roundtable-structure",
|
||||
);
|
||||
});
|
||||
|
||||
test("reuses the thread-bound agent when reopening an iframe thread", () => {
|
||||
assert.equal(
|
||||
resolveIframeAssistantId({
|
||||
selectedAgentId: null,
|
||||
threadMetadataAgentId: "roundtable-structure",
|
||||
}),
|
||||
"roundtable-structure",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to lead-agent mode when no agent is selected or bound", () => {
|
||||
assert.equal(
|
||||
resolveIframeAssistantId({
|
||||
selectedAgentId: null,
|
||||
threadMetadataAgentId: null,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
type ResolveIframeAssistantIdInput = {
|
||||
selectedAgentId: string | null;
|
||||
threadMetadataAgentId: string | null;
|
||||
};
|
||||
|
||||
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
|
||||
const trimmed = agentId?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
}: ResolveIframeAssistantIdInput): string | undefined {
|
||||
return (
|
||||
normalizeAgentId(selectedAgentId) ??
|
||||
normalizeAgentId(threadMetadataAgentId)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-ignore Node's test runner resolves the local .ts module directly.
|
||||
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
|
||||
|
||||
test("includes the context compression toggle in the iframe chat toolbar", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
contextCompressionEnabled: false,
|
||||
proModeEnabled: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
buttons.map((button) => button.key),
|
||||
[
|
||||
"writing",
|
||||
"memory",
|
||||
"references",
|
||||
"contextCompression",
|
||||
"pro",
|
||||
"agent",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns only concrete toolbar button configs", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: true,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
contextCompressionEnabled: false,
|
||||
proModeEnabled: false,
|
||||
});
|
||||
|
||||
assert.equal(buttons.every((button) => typeof button.key === "string"), true);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
export type IframeModeButtonState = {
|
||||
key:
|
||||
| "agent"
|
||||
| "writing"
|
||||
| "memory"
|
||||
| "references"
|
||||
| "contextCompression"
|
||||
| "pro";
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type BuildIframeModeButtonsInput = {
|
||||
agentSelected: boolean;
|
||||
writingModeEnabled: boolean;
|
||||
memoryInjectionEnabled: boolean;
|
||||
ragModeEnabled: boolean;
|
||||
ragModeLocked: boolean;
|
||||
contextCompressionEnabled: boolean;
|
||||
proModeEnabled: boolean;
|
||||
};
|
||||
|
||||
export function buildIframeModeButtons({
|
||||
agentSelected,
|
||||
writingModeEnabled,
|
||||
memoryInjectionEnabled,
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
contextCompressionEnabled,
|
||||
proModeEnabled,
|
||||
}: BuildIframeModeButtonsInput): IframeModeButtonState[] {
|
||||
return [
|
||||
{
|
||||
key: "writing",
|
||||
label: "写作",
|
||||
active: writingModeEnabled,
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: "记忆",
|
||||
active: memoryInjectionEnabled,
|
||||
},
|
||||
{
|
||||
key: "references",
|
||||
label: "参考文献",
|
||||
active: ragModeEnabled,
|
||||
disabled: ragModeLocked,
|
||||
},
|
||||
{
|
||||
key: "contextCompression",
|
||||
label: "上下文压缩",
|
||||
active: contextCompressionEnabled,
|
||||
},
|
||||
{
|
||||
key: "pro",
|
||||
label: "专业问答",
|
||||
active: proModeEnabled,
|
||||
},
|
||||
{
|
||||
key: "agent",
|
||||
label: "智能体",
|
||||
active: agentSelected,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
@@ -321,7 +322,14 @@ export function fromUpdate(p: DraftUpdatePayload) {
|
||||
export async function listDrafts(): Promise<DraftMeta[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts");
|
||||
return (data.drafts ?? []).map(toMeta);
|
||||
const metas = (data.drafts ?? []).map(toMeta);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-会商-查询草稿",
|
||||
url: BASE(),
|
||||
returnParam: { count: metas.length },
|
||||
});
|
||||
return metas;
|
||||
}
|
||||
|
||||
/** Fetch the full draft (incl. step1/step2). Returns null on 404. */
|
||||
@@ -339,7 +347,15 @@ export async function createDraft(payload: DraftCreatePayload): Promise<DraftRec
|
||||
body: JSON.stringify(fromCreate(payload)),
|
||||
});
|
||||
const data = await readJson<ServerDraftFull>(res, "create draft");
|
||||
return toRecord(data);
|
||||
const record = toRecord(data);
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "B1-CM助手-会商-新建草稿",
|
||||
url: BASE(),
|
||||
reqParam: { title: payload.title, furthestStep: payload.furthestStep, taskId: payload.taskId ?? null },
|
||||
returnParam: { id: record.id, title: record.title },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
|
||||
@@ -350,7 +366,15 @@ export async function updateDraft(id: string, patch: DraftUpdatePayload): Promis
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerDraftFull>(res, "update draft");
|
||||
return toRecord(data);
|
||||
const record = toRecord(data);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-会商-更新草稿",
|
||||
url: `${BASE()}/${id}`,
|
||||
reqParam: { id, ...patch },
|
||||
returnParam: { id: record.id, title: record.title },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function deleteDraft(id: string): Promise<void> {
|
||||
@@ -359,6 +383,12 @@ export async function deleteDraft(id: string): Promise<void> {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`delete draft failed (${res.status}): ${text}`);
|
||||
}
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "B1-CM助手-会商-删除草稿",
|
||||
url: `${BASE()}/${id}`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
import { reportRoundtableError } from "./diagnostics-report";
|
||||
@@ -129,7 +130,15 @@ export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toStartBody(payload)),
|
||||
});
|
||||
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
|
||||
const job = mapJob(await readJson<Record<string, unknown>>(res, "start job"));
|
||||
writeYLog({
|
||||
optType: OptType.other,
|
||||
optDetail: "B1-CM助手-会商-运行",
|
||||
url: BASE(),
|
||||
reqParam: { draftId: payload.draftId ?? null, taskId: payload.taskId ?? null, model: payload.model },
|
||||
returnParam: { id: job.id, status: job.status },
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,7 +195,15 @@ export async function resumeJob(jobId: string, answer: string, taskId?: string |
|
||||
/** 取消作业。`taskId` 非空 → task 共享取消(不分权)。 */
|
||||
export async function cancelJob(jobId: string, taskId?: string | null): Promise<RoundtableJob> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel${taskQuery(taskId, "?")}`, { method: "POST" });
|
||||
return mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
|
||||
const job = mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-会商-取消",
|
||||
url: `${BASE()}/${jobId}/cancel`,
|
||||
reqParam: { jobId, taskId: taskId ?? null },
|
||||
returnParam: { id: job.id, status: job.status },
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
// ── SSE 进度订阅 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import {
|
||||
fromCreate,
|
||||
@@ -33,7 +34,15 @@ const BASE = () => `${getBackendBaseURL()}/api/roundtable-task-drafts`;
|
||||
export async function listTaskDraftsByTask(taskId: string): Promise<DraftMeta[]> {
|
||||
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
|
||||
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list task drafts");
|
||||
return (data.drafts ?? []).map(toMeta);
|
||||
const metas = (data.drafts ?? []).map(toMeta);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-会商任务-查询草稿",
|
||||
url: `${BASE()}/by-task/${taskId}`,
|
||||
reqParam: { taskId },
|
||||
returnParam: { count: metas.length },
|
||||
});
|
||||
return metas;
|
||||
}
|
||||
|
||||
/** 按外部任务 id 取最新一条聊天记录(不分权)。无则 null(404)。 */
|
||||
@@ -60,7 +69,15 @@ export async function createTaskDraft(payload: DraftCreatePayload): Promise<Draf
|
||||
body: JSON.stringify(fromCreate(payload)),
|
||||
});
|
||||
const data = await readJson<ServerDraftFull>(res, "create task draft");
|
||||
return toRecord(data);
|
||||
const record = toRecord(data);
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "B1-CM助手-会商任务-新建草稿",
|
||||
url: BASE(),
|
||||
reqParam: { taskId: payload.taskId ?? null, title: payload.title, furthestStep: payload.furthestStep },
|
||||
returnParam: { id: record.id, title: record.title },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
|
||||
@@ -71,7 +88,15 @@ export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Pr
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerDraftFull>(res, "update task draft");
|
||||
return toRecord(data);
|
||||
const record = toRecord(data);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-会商任务-更新草稿",
|
||||
url: `${BASE()}/${id}`,
|
||||
reqParam: { id, ...patch },
|
||||
returnParam: { id: record.id, title: record.title },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function deleteTaskDraft(id: string): Promise<void> {
|
||||
@@ -80,4 +105,10 @@ export async function deleteTaskDraft(id: string): Promise<void> {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`delete task draft failed (${res.status}): ${text}`);
|
||||
}
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "B1-CM助手-会商任务-删除草稿",
|
||||
url: `${BASE()}/${id}`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { parseThemeMap, stringifyThemeMap } from "@/strategy-components/lib/light-app";
|
||||
|
||||
export type LightAppType = "window_open" | "iframe";
|
||||
@@ -156,7 +157,15 @@ export async function listLightApps(params: ListLightAppsParams = {}): Promise<L
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
|
||||
const data = await readJson<{ apps: ServerApp[] }>(res, "list light apps");
|
||||
return (data.apps ?? []).map(toApp);
|
||||
const apps = (data.apps ?? []).map(toApp);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-轻应用-列表",
|
||||
url: `${BASE()}${query ? `?${query}` : ""}`,
|
||||
reqParam: params,
|
||||
returnParam: apps,
|
||||
});
|
||||
return apps;
|
||||
}
|
||||
|
||||
export async function getLightApp(appId: string): Promise<LightApp> {
|
||||
@@ -170,7 +179,15 @@ export async function createLightApp(input: LightAppInput): Promise<LightApp> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(input)),
|
||||
});
|
||||
return toApp(await readJson<ServerApp>(res, "create light app"));
|
||||
const app = toApp(await readJson<ServerApp>(res, "create light app"));
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "B1-CM助手-轻应用-注册",
|
||||
url: BASE(),
|
||||
reqParam: input,
|
||||
returnParam: app,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function updateLightApp(
|
||||
@@ -182,12 +199,26 @@ export async function updateLightApp(
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(patch)),
|
||||
});
|
||||
return toApp(await readJson<ServerApp>(res, "update light app"));
|
||||
const app = toApp(await readJson<ServerApp>(res, "update light app"));
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-轻应用-修改",
|
||||
url: `${BASE()}/${encodeURIComponent(appId)}`,
|
||||
reqParam: { appId, patch },
|
||||
returnParam: app,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function deleteLightApp(appId: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "DELETE" });
|
||||
await readJson<{ success: boolean }>(res, "delete light app");
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "B1-CM助手-轻应用-删除",
|
||||
url: `${BASE()}/${encodeURIComponent(appId)}`,
|
||||
reqParam: { appId },
|
||||
});
|
||||
}
|
||||
|
||||
// ── React Query hooks(管理页 / 列表页 / 侧边栏共用缓存) ───────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import type { MenuOverride } from "@/core/page-layout/menu-overrides";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/menu-overrides`;
|
||||
@@ -67,7 +68,15 @@ export async function replaceMenuOverrides(overrides: MenuOverride[]): Promise<M
|
||||
body: JSON.stringify({ overrides: overrides.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ overrides: ServerOverride[] }>(res, "save menu overrides");
|
||||
return (data.overrides ?? []).map(toOverride);
|
||||
const saved = (data.overrides ?? []).map(toOverride);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-菜单管理-保存",
|
||||
url: "/api/menu-overrides",
|
||||
reqParam: { overrides },
|
||||
returnParam: saved,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ── React Query hooks(侧边栏 + 管理页共用缓存) ────────────────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/sensitive-words`;
|
||||
|
||||
@@ -71,7 +72,14 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
export async function listSensitiveWords(): Promise<SensitiveWord[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ words: ServerWord[] }>(res, "list sensitive words");
|
||||
return (data.words ?? []).map(toWord);
|
||||
const words = (data.words ?? []).map(toWord);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-敏感词管理-列表",
|
||||
url: "/api/sensitive-words",
|
||||
returnParam: words,
|
||||
});
|
||||
return words;
|
||||
}
|
||||
|
||||
export async function replaceSensitiveWords(words: SensitiveWord[]): Promise<SensitiveWord[]> {
|
||||
@@ -81,7 +89,15 @@ export async function replaceSensitiveWords(words: SensitiveWord[]): Promise<Sen
|
||||
body: JSON.stringify({ words: words.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ words: ServerWord[] }>(res, "save sensitive words");
|
||||
return (data.words ?? []).map(toWord);
|
||||
const saved = (data.words ?? []).map(toWord);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-敏感词管理-保存",
|
||||
url: "/api/sensitive-words",
|
||||
reqParam: { words },
|
||||
returnParam: saved,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** 把「启用」的词条折叠成运行时脱敏所需的 `原始词 → 替换词` 映射。 */
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/skill-addresses`;
|
||||
|
||||
@@ -197,7 +198,15 @@ export async function scanAddresses(kinds?: AddressKind[], skill?: string): Prom
|
||||
if (skill) params.set("skill", skill);
|
||||
const qs = params.toString();
|
||||
const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" });
|
||||
return toScanResult(await readJson<any>(res, "扫描"));
|
||||
const result = toScanResult(await readJson<any>(res, "扫描"));
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-技能地址管理-扫描",
|
||||
url: `${BASE()}/scan${qs ? `?${qs}` : ""}`,
|
||||
reqParam: { kinds, skill },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 流式扫描:逐技能上报进度,返回最终聚合结果。 */
|
||||
@@ -216,6 +225,13 @@ export async function scanAddressesStream(
|
||||
else if (ev.type === "result") result = toScanResult(ev);
|
||||
});
|
||||
if (!result) throw new Error("扫描未返回结果");
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-技能地址管理-扫描",
|
||||
url: `${BASE()}/scan/stream${qs ? `?${qs}` : ""}`,
|
||||
reqParam: { kinds: opts.kinds, skill: opts.skill },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -251,6 +267,13 @@ export async function applyReplacementsStream(
|
||||
else if (ev.type === "result") result = toApplyResult(ev);
|
||||
});
|
||||
if (!result) throw new Error("应用未返回结果");
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-技能地址管理-应用替换",
|
||||
url: `${BASE()}/apply/stream`,
|
||||
reqParam: body,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -269,7 +292,15 @@ export async function listBackups(): Promise<BackupEntry[]> {
|
||||
export async function rollbackBackup(backupId: string): Promise<{ restored: number }> {
|
||||
const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" });
|
||||
const data = await readJson<{ restored_files: string[] }>(res, "撤销");
|
||||
return { restored: (data.restored_files ?? []).length };
|
||||
const result = { restored: (data.restored_files ?? []).length };
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-技能地址管理-回退",
|
||||
url: `${BASE()}/rollback/${encodeURIComponent(backupId)}`,
|
||||
reqParam: { backupId },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── React Query ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import type {
|
||||
TaskBusiness,
|
||||
TaskButton,
|
||||
@@ -91,7 +92,15 @@ export async function replaceTaskButtons(buttons: TaskButton[]): Promise<TaskBut
|
||||
body: JSON.stringify({ buttons: buttons.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ buttons: ServerButton[] }>(res, "save task buttons");
|
||||
return (data.buttons ?? []).map(toButton);
|
||||
const saved = (data.buttons ?? []).map(toButton);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-按钮管理-保存",
|
||||
url: "/api/task-buttons",
|
||||
reqParam: { buttons },
|
||||
returnParam: saved,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ── React Query hooks(工作区 + 管理页共用缓存) ────────────────────────────
|
||||
|
||||
+113
-5
@@ -29,7 +29,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
|
||||
import {
|
||||
deleteTaskPurpose,
|
||||
fetchActionHistoryExists,
|
||||
readTaskContext,
|
||||
type ActionHistoryResult,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import {
|
||||
fetchActionDetail,
|
||||
readCopTaskDetail,
|
||||
@@ -43,7 +48,7 @@ import { cn } from "@/lib/utils";
|
||||
import AgentChatPage from "@/pages/AgentChatPage";
|
||||
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
|
||||
import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar";
|
||||
import { RwfxSqReportGate } from "@/strategy-components/components/task-components/RwfxSqReportGate";
|
||||
import { RwfxSqGateProvider } from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
|
||||
function useChatsBase(taskBase: string): {
|
||||
taskId: string;
|
||||
@@ -195,6 +200,8 @@ function TaskConversationsList({
|
||||
<PlusSquare className="h-4 w-4" /> 新建对话
|
||||
</Button>
|
||||
{!lite && <TaskClearButton taskId={taskId} />}
|
||||
{/* xdfx:按「行动id」清除行动记录(删除前查历史是否存在;与 rwfx 共用同一删除接口)。 */}
|
||||
{business === "xdfx" && actionId && <ActionClearButton actionId={actionId} />}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
@@ -292,6 +299,103 @@ function TaskClearButton({ taskId }: { taskId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「清除记录」按钮(xdfx 行动分析):放在「新建对话」下面,按「行动id」清除该行动在 B 系统里的
|
||||
* 策划数据(聊天对话保留),与 rwfx 共用同一个删除接口(仅传 ``actionId``、不传 taskId)。
|
||||
*
|
||||
* 打开弹框时先「查询历史是否存在」(``fetchActionHistoryExists``):
|
||||
* - 接口未就绪(地址未配置)→ ``known=false``:当作未知,仍可删除(不拦截);
|
||||
* - 查到「无历史」(``known && !exists``)→ 禁用「确认清除」(没有可删的数据)。
|
||||
*/
|
||||
function ActionClearButton({ actionId }: { actionId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [history, setHistory] = useState<ActionHistoryResult | null>(null);
|
||||
|
||||
// 打开弹框时查历史是否存在(接口未配置 → known=false,不影响删除)。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
setChecking(true);
|
||||
setHistory(null);
|
||||
void fetchActionHistoryExists(actionId)
|
||||
.then((r) => {
|
||||
if (alive) setHistory(r);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setChecking(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [open, actionId]);
|
||||
|
||||
const handleClear = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const r = await deleteTaskPurpose(actionId, { idKind: "action" });
|
||||
if (r.success) toast.success("已清除行动记录");
|
||||
else toast.error(r.message || "清除失败");
|
||||
} catch {
|
||||
toast.error("清除失败,请稍后再试");
|
||||
} finally {
|
||||
setClearing(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 仅当「明确查到无历史」时禁用删除;接口未就绪(known=false)仍允许删除。
|
||||
const noHistory = history?.known === true && history.exists === false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35 mt-2 h-9 w-full"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={!actionId}
|
||||
title="清除行动记录"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 清除记录
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={(o) => !clearing && setOpen(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>清除行动记录</DialogTitle>
|
||||
<DialogDescription>
|
||||
将清除该行动在 B 系统中的策划数据(聊天对话保留),是否确认?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{checking ? (
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 正在查询历史…
|
||||
</div>
|
||||
) : history?.known === true ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{history.exists ? "检测到该行动的历史记录。" : "未检测到该行动的历史记录,无需清除。"}
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={clearing}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClear}
|
||||
disabled={clearing || checking || noHistory}
|
||||
>
|
||||
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务工作区布局:左列表 + 右 Outlet。
|
||||
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
|
||||
@@ -306,7 +410,7 @@ export function TaskFxWorkspace({
|
||||
lite?: boolean;
|
||||
}) {
|
||||
const { taskId, agentId, actionId, chatsBase } = useChatsBase(taskBase);
|
||||
return (
|
||||
const content = (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<TaskConversationsList
|
||||
@@ -322,10 +426,14 @@ export function TaskFxWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
<TaskJumpBar business={business} taskId={taskId} />
|
||||
{/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */}
|
||||
{business === "rwfx" && <RwfxSqReportGate taskId={taskId} />}
|
||||
</div>
|
||||
);
|
||||
// rwfx 专属:3q 预检门控。进入时若 3q 未完成弹「前往 3qfx」框;并门控目的树为空时的
|
||||
// 自动发送——3q 无数据时不默认发送,仅当用户点「暂不(不去 3q 分析)」才发送。
|
||||
if (business === "rwfx") {
|
||||
return <RwfxSqGateProvider taskId={taskId}>{content}</RwfxSqGateProvider>;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
|
||||
|
||||
+5
-1
@@ -31,6 +31,7 @@ import {
|
||||
type TaskPurposeResult,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink";
|
||||
import { useRwfxSqGate } from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -176,6 +177,8 @@ export function TaskPurposePanel({
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
// rwfx 空数据自动开始:防止重复触发。
|
||||
const autoStartedRef = useRef(false);
|
||||
// rwfx 3q 预检门控:3q 无数据时不默认发送,交由门控(仅「暂不」才发送)。
|
||||
const { gateStart } = useRwfxSqGate();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -185,9 +188,10 @@ export function TaskPurposePanel({
|
||||
if (cancelled) return;
|
||||
setResult(r);
|
||||
// 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。
|
||||
// 经 3q 门控:3q 完成/未知立即发送;3q 未完成则挂起,仅当用户「暂不(不去 3q 分析)」才发送。
|
||||
if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) {
|
||||
autoStartedRef.current = true;
|
||||
onStart();
|
||||
gateStart(onStart);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* rwfx(任务分析)专用「3q 预检门控」。
|
||||
*
|
||||
* 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成:
|
||||
* - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。
|
||||
* - ``complete`` / ``unknown`` → 不打扰。
|
||||
*
|
||||
* 关键点:rwfx「目的树为空时自动发送第一次问题」必须**经本门控**(``gateStart``),不能在 3q
|
||||
* 无数据时还默认发送:
|
||||
* - 3q 完成 / 无法判定 → 立即发送;
|
||||
* - 3q 未完成 → 挂起,**仅当用户点「暂不」(不去 3q 分析)才发送**;点「前往 3qfx」则跳转、丢弃发送。
|
||||
*
|
||||
* Provider 由 ``TaskFxWorkspace``(仅 rwfx)包裹整个工作区;``TaskPurposePanel`` 经 ``useRwfxSqGate``
|
||||
* 读取门控。非 rwfx / 无 Provider → ``gateStart`` 直接发送(默认值)。见 [[dashboard-input]] / [[task-deeplink]]。
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink";
|
||||
|
||||
type GateStatus = "loading" | "incomplete" | "ok";
|
||||
|
||||
interface RwfxSqGateValue {
|
||||
/**
|
||||
* rwfx 自动开始第一次问答的门控。
|
||||
* - 3q 完成 / 无法判定 → 立即 ``start()``。
|
||||
* - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。
|
||||
* 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。
|
||||
*/
|
||||
gateStart: (start: () => void) => void;
|
||||
}
|
||||
|
||||
const RwfxSqGateContext = createContext<RwfxSqGateValue>({
|
||||
gateStart: (start) => start(),
|
||||
});
|
||||
|
||||
export function useRwfxSqGate(): RwfxSqGateValue {
|
||||
return useContext(RwfxSqGateContext);
|
||||
}
|
||||
|
||||
export function RwfxSqGateProvider({
|
||||
taskId,
|
||||
children,
|
||||
}: {
|
||||
taskId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [jumping, setJumping] = useState(false);
|
||||
|
||||
const statusRef = useRef<GateStatus>(taskId ? "loading" : "ok");
|
||||
// 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。
|
||||
const pendingStartRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// taskId 变化:重置门控状态并预检 3q。
|
||||
useEffect(() => {
|
||||
pendingStartRef.current = null;
|
||||
setOpen(false);
|
||||
if (!taskId) {
|
||||
statusRef.current = "ok";
|
||||
return;
|
||||
}
|
||||
statusRef.current = "loading";
|
||||
let alive = true;
|
||||
void fetchSqReportStatus(taskId).then((s) => {
|
||||
if (!alive) return;
|
||||
// complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。
|
||||
const next: GateStatus = s === "incomplete" ? "incomplete" : "ok";
|
||||
statusRef.current = next;
|
||||
if (next === "incomplete") {
|
||||
setOpen(true); // 进入即提醒(无论是否有挂起的自动发送)
|
||||
} else if (pendingStartRef.current) {
|
||||
// 加载期间已请求自动开始 → 放行后补发。
|
||||
const start = pendingStartRef.current;
|
||||
pendingStartRef.current = null;
|
||||
start();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const gateStart = useCallback((start: () => void) => {
|
||||
const status = statusRef.current;
|
||||
if (status === "ok") {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
// loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。
|
||||
pendingStartRef.current = start;
|
||||
if (status === "incomplete") setOpen(true);
|
||||
}, []);
|
||||
|
||||
/** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */
|
||||
const stayAndStart = useCallback(() => {
|
||||
setOpen(false);
|
||||
const start = pendingStartRef.current;
|
||||
pendingStartRef.current = null;
|
||||
start?.();
|
||||
}, []);
|
||||
|
||||
const handleGo3qfx = async () => {
|
||||
const detail = readCopTaskDetail();
|
||||
if (!detail || !taskId) {
|
||||
toast.error("缺少任务信息,无法跳转");
|
||||
return;
|
||||
}
|
||||
setJumping(true);
|
||||
try {
|
||||
// 复用深链解析:清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。
|
||||
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
|
||||
if (target) {
|
||||
pendingStartRef.current = null; // 去 3q 分析 → 丢弃自动发送
|
||||
setOpen(false);
|
||||
navigate(target.path, { state: target.state });
|
||||
} else {
|
||||
toast.error("未配置三情分析(3qfx)对应的业务智能体");
|
||||
}
|
||||
} catch {
|
||||
toast.error("跳转失败,请稍后再试");
|
||||
} finally {
|
||||
setJumping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = useMemo<RwfxSqGateValue>(() => ({ gateStart }), [gateStart]);
|
||||
|
||||
return (
|
||||
<RwfxSqGateContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (jumping) return;
|
||||
if (o) setOpen(true);
|
||||
else stayAndStart(); // 关闭(暂不/ESC/点外部)= 不去 3q 分析 → 发送
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>3q 未完成</DialogTitle>
|
||||
<DialogDescription>
|
||||
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={stayAndStart} disabled={jumping}>
|
||||
暂不
|
||||
</Button>
|
||||
<Button
|
||||
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故用全局按主题
|
||||
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
|
||||
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
|
||||
onClick={handleGo3qfx}
|
||||
disabled={jumping}
|
||||
>
|
||||
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : "前往 3qfx"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</RwfxSqGateContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -26,3 +26,10 @@ async def get_task_deeplink_config() -> dict:
|
||||
"""返回 ``config.yaml`` 的 ``task_deeplink`` 段(含 test/delete_test/各 URL/mock)。"""
|
||||
config = get_app_config()
|
||||
return config.task_deeplink.model_dump()
|
||||
|
||||
|
||||
@router.get("/y-log")
|
||||
async def get_y_log_config() -> dict:
|
||||
"""返回 ``config.yaml`` 的 ``y_log`` 段(enabled / write_url),无密钥,公开只读。"""
|
||||
config = get_app_config()
|
||||
return config.y_log.model_dump()
|
||||
@@ -314,7 +314,7 @@ def build_run_config(
|
||||
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
||||
identically.
|
||||
"""
|
||||
config: dict[str, Any] = {"recursion_limit": 100}
|
||||
config: dict[str, Any] = {"recursion_limit": 250}
|
||||
if request_config:
|
||||
# LangGraph >= 0.6.0 introduced ``context`` as the preferred way to
|
||||
# pass thread-level data and rejects requests that include both
|
||||
|
||||
@@ -431,11 +431,14 @@ task_deeplink:
|
||||
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
|
||||
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
|
||||
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
|
||||
delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete"
|
||||
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
|
||||
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
|
||||
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
|
||||
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
|
||||
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
|
||||
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
|
||||
# xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。
|
||||
action_history_url: ""
|
||||
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
|
||||
detail_3q_url: ""
|
||||
mock:
|
||||
|
||||
+10
-10
@@ -28,12 +28,12 @@ from langgraph.runtime import Runtime
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Defaults — can be overridden via constructor
|
||||
_DEFAULT_WARN_THRESHOLD = 3 # inject warning after 3 identical calls
|
||||
_DEFAULT_HARD_LIMIT = 5 # force-stop after 5 identical calls
|
||||
_DEFAULT_WINDOW_SIZE = 20 # track last N tool calls
|
||||
_DEFAULT_WARN_THRESHOLD = 8 # inject warning after 8 identical calls
|
||||
_DEFAULT_HARD_LIMIT = 20 # force-stop after 20 identical calls
|
||||
_DEFAULT_WINDOW_SIZE = 60 # track last N tool calls (must be >= hard_limit)
|
||||
_DEFAULT_MAX_TRACKED_THREADS = 100 # LRU eviction limit
|
||||
_DEFAULT_TOOL_FREQ_WARN = 30 # warn after 30 calls to the same tool type
|
||||
_DEFAULT_TOOL_FREQ_HARD_LIMIT = 50 # force-stop after 50 calls to the same tool type
|
||||
_DEFAULT_TOOL_FREQ_WARN = 80 # warn after 80 calls to the same tool type
|
||||
_DEFAULT_TOOL_FREQ_HARD_LIMIT = 120 # force-stop after 120 calls to the same tool type
|
||||
|
||||
|
||||
def _normalize_tool_call_args(raw_args: object) -> tuple[dict, str | None]:
|
||||
@@ -142,19 +142,19 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
|
||||
Args:
|
||||
warn_threshold: Number of identical tool call sets before injecting
|
||||
a warning message. Default: 3.
|
||||
a warning message. Default: 8.
|
||||
hard_limit: Number of identical tool call sets before stripping
|
||||
tool_calls entirely. Default: 5.
|
||||
tool_calls entirely. Default: 20.
|
||||
window_size: Size of the sliding window for tracking calls.
|
||||
Default: 20.
|
||||
Default: 60.
|
||||
max_tracked_threads: Maximum number of threads to track before
|
||||
evicting the least recently used. Default: 100.
|
||||
tool_freq_warn: Number of calls to the same tool *type* (regardless
|
||||
of arguments) before injecting a frequency warning. Catches
|
||||
cross-file read loops that hash-based detection misses.
|
||||
Default: 30.
|
||||
Default: 80.
|
||||
tool_freq_hard_limit: Number of calls to the same tool type before
|
||||
forcing a stop. Default: 50.
|
||||
forcing a stop. Default: 120.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -39,6 +39,7 @@ from deerflow.config.token_usage_config import TokenUsageConfig
|
||||
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
|
||||
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
|
||||
from deerflow.config.vision_config import VisionConfig
|
||||
from deerflow.config.y_log_config import YLogConfig
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -141,6 +142,7 @@ class AppConfig(BaseModel):
|
||||
knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
|
||||
knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration")
|
||||
task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)")
|
||||
y_log: YLogConfig = Field(default_factory=YLogConfig, description="操作日志上报配置(前端经 /api/public/config/y-log 读取)")
|
||||
|
||||
@classmethod
|
||||
def resolve_config_path(cls, config_path: str | None = None) -> Path:
|
||||
|
||||
+10
-1
@@ -8,8 +8,13 @@
|
||||
- ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据;
|
||||
同时 rwfx 进入「3q 是否完成」预检(``selectSqReport``,外部接口不可用)走 ``mock.sq_report`` 假数据。
|
||||
- ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。
|
||||
rwfx 与 xdfx 共用同一个删除接口 ``dropSixOrSevenData``:rwfx 传 ``?taskId=``、xdfx 传 ``?actionId=``
|
||||
(另一参数直接不传,不出现在 URL 里)。
|
||||
- ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。
|
||||
xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。
|
||||
|
||||
``action_history_url`` 为 xdfx 删除前「行动历史是否存在」预检接口,地址待提供:留空则前端视作
|
||||
「无历史/不查询」,不阻断删除(后续配上真实地址即生效)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,13 +49,17 @@ class TaskDeeplinkConfig(BaseModel):
|
||||
default="",
|
||||
description="行动详情接口基址(xdfx 行动→任务),调用时追加 /{actionId} 路径段;返回 data.taskId 为真正任务id",
|
||||
)
|
||||
action_history_url: str = Field(
|
||||
default="",
|
||||
description="xdfx「行动历史是否存在」查询完整 URL(删除前预检),调用时追加 ?actionId=;地址待提供,留空则前端视作「无历史/不查询」(不阻断删除)",
|
||||
)
|
||||
detail_3q_url: str = Field(
|
||||
default="",
|
||||
description="「3q详情」详情页 URL(rwfx 目的树面板的链接),跳转时追加 ?id=(任务id)",
|
||||
)
|
||||
delete_url: str = Field(
|
||||
default="",
|
||||
description="删除完整 URL,调用时追加 ?taskId=",
|
||||
description="删除完整 URL(rwfx 与 xdfx 共用同一个删除接口 dropSixOrSevenData):rwfx 调用追加 ?taskId=(不传 actionId),xdfx 调用追加 ?actionId=(不传 taskId)——另一参数直接不出现在 URL 里,不是空值",
|
||||
)
|
||||
treemap_edit_url: str = Field(
|
||||
default="",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""操作日志(Y-Log)上报配置。
|
||||
|
||||
前端经公开只读接口 ``GET /api/public/config/y-log`` 读取本段(地址 + 开关),
|
||||
据此决定是否把用户的关键操作上报到外部日志接口。段内只有地址与开关,无密钥,
|
||||
公开只读。仿 :mod:`deerflow.config.log_system_config` 的写法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class YLogConfig(BaseModel):
|
||||
"""操作日志(Y-Log)上报配置:前端经 /api/public/config/y-log 读取。"""
|
||||
|
||||
enabled: bool = Field(default=False, description="是否启用操作日志上报")
|
||||
write_url: str = Field(
|
||||
default="",
|
||||
description="日志上报外部接口完整地址(POST),如 https://ch1.b.uat.4.cn/api/y-log/write",
|
||||
)
|
||||
@@ -308,6 +308,14 @@ log_system:
|
||||
url: "https://your-log-system.example.com"
|
||||
label: "日志系统"
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# 操作日志(Y-Log)上报:前端直连外部接口记录用户的关键操作(审计/统计)。
|
||||
# 仅在「token 登录」(存在 token1)时上报;前端经公开只读接口
|
||||
# GET /api/public/config/y-log 读取本段,改这里即时生效(Gateway mtime 热重载)。
|
||||
y_log:
|
||||
enabled: true
|
||||
write_url: https://ch1.b.uat.4.cn/api/y-log/write
|
||||
|
||||
summarization:
|
||||
# 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。
|
||||
# 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep
|
||||
@@ -341,11 +349,14 @@ task_deeplink:
|
||||
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
|
||||
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
|
||||
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
|
||||
delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete"
|
||||
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
|
||||
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
|
||||
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
|
||||
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
|
||||
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
|
||||
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
|
||||
# xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。
|
||||
action_history_url: ""
|
||||
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
|
||||
detail_3q_url: ""
|
||||
mock:
|
||||
|
||||
Reference in new issue
Block a user