From 19d3cb96dcfab0af46122bf374b1f3241a30afa4 Mon Sep 17 00:00:00 2001 From: gozeros Date: Wed, 21 Oct 2026 15:11:11 +0800 Subject: [PATCH] sync update from LAN repo (source master-yx @ cf505220) --- .gitignore | 13 +- CLAUDE.md | 1 + frontend-web/.env.development | 2 +- .../workspace/agents/agent-chat-sidebar.tsx | 16 +- .../workspace/agents/agent-recent-chats.tsx | 25 +- .../workspace/general-qa-sidebar.tsx | 16 +- .../workspace/model-name-select.tsx | 4 +- .../workspace/my-project-thread-indicator.tsx | 51 + .../components/workspace/recent-chat-list.tsx | 30 +- .../workspace/settings/settings-dialog.tsx | 2 +- .../workspace/workspace-nav-chat-list.tsx | 24 +- .../src/core/page-layout/sidebar-menu.ts | 30 +- frontend-web/src/core/system-settings/api.ts | 38 + .../src/core/system-settings/hooks.ts | 25 + .../src/core/threads/project-metadata.test.ts | 46 + .../src/core/threads/project-metadata.ts | 94 + .../eightbf-roundtable/api/task-context.ts | 12 +- .../pages/EightBfRoundtablePage.tsx | 17 +- frontend-web/src/pages/ChatPage.tsx | 42 +- .../src/pages/OfficialDeerFlowEntry.tsx | 126 +- frontend-web/src/pages/StrategyRoutes.tsx | 23 +- .../src/pages/official-deerflow-entry.css | 2 + .../hooks/useTaskArtifactSubmission.ts | 4 +- .../roundtable-planning/lib/business-chain.ts | 7 + .../lib/task-artifact-submission.ts | 7 + .../pages/BusinessChainEditorPage.tsx | 2 + .../src/strategy-components/Header.tsx | 11 +- .../src/strategy-components/api/llmwiki.ts | 57 +- .../llmwiki/WeKnoraKnowledgeWorkspace.tsx | 147 +- .../llmwiki/WeKnoraWikiManagementPage.tsx | 33 +- .../strategy-selection/AgentConfigDialog.tsx | 736 ++++ .../StrategyProjectsPage.tsx | 1180 ++++++ .../StrategySelectionPage.tsx | 3666 +++++++++++++++++ frontend-web/src/strategy-selection/api.ts | 44 + .../src/strategy-selection/constants.ts | 350 ++ .../src/strategy-selection/lib.test.ts | 329 ++ frontend-web/src/strategy-selection/lib.ts | 408 ++ .../strategy-selection/project-routes.test.ts | 29 + .../src/strategy-selection/project-routes.ts | 28 + .../strategy-selection/project-wal.test.ts | 147 + .../src/strategy-selection/project-wal.ts | 362 ++ .../src/strategy-selection/projects.ts | 469 +++ .../strategy-agent-mapping.ts | 112 + .../strategy-agents.test.ts | 66 + .../src/strategy-selection/strategy-agents.ts | 128 + frontend-web/src/strategy-selection/types.ts | 128 + offline-backend-20260512/backend/CLAUDE.md | 50 +- offline-backend-20260512/backend/README.md | 35 +- .../backend/app/gateway/app.py | 46 +- .../backend/app/gateway/deps.py | 98 +- .../app/gateway/llmwiki_index_runtime.py | 369 ++ .../app/gateway/llmwiki_index_scheduler.py | 59 +- .../routers/_strategy_selection_seed.py | 207 + .../SOUL.md | 35 + .../config.yaml | 8 + .../strategy-selection-angle-economy/SOUL.md | 33 + .../config.yaml | 8 + .../SOUL.md | 35 + .../config.yaml | 8 + .../SOUL.md | 34 + .../config.yaml | 8 + .../SOUL.md | 34 + .../config.yaml | 8 + .../strategy-selection-angle-security/SOUL.md | 34 + .../config.yaml | 8 + .../strategy-selection-intent/SOUL.md | 52 + .../strategy-selection-intent/config.yaml | 6 + .../strategy-selection-judge/SOUL.md | 65 + .../strategy-selection-judge/config.yaml | 5 + .../strategy-selection-solution/SOUL.md | 42 + .../strategy-selection-solution/config.yaml | 6 + .../backend/app/gateway/routers/agents.py | 278 +- .../backend/app/gateway/routers/llmwiki.py | 122 +- .../app/gateway/routers/llmwiki_index.py | 104 +- .../gateway/routers/parallel_agent_tasks.py | 20 +- .../app/gateway/routers/parallel_agents.py | 300 +- .../app/gateway/routers/roundtable_chains.py | 4 + .../app/gateway/routers/strategy_agents.py | 802 ++++ .../app/gateway/routers/system_settings.py | 38 + offline-backend-20260512/backend/config.yaml | 8 +- .../backend/docs/summarization.md | 21 +- .../backend/extensions_config.json | 2 +- .../llm_error_handling_middleware.py | 14 +- .../middlewares/summarization_middleware.py | 25 +- .../harness/deerflow/config/app_config.py | 31 + .../harness/deerflow/config/llmwiki_config.py | 1 + .../deerflow/config/system_settings.py | 34 +- .../weknora/local_index/embedding.py | 112 +- .../integrations/weknora/local_index/sync.py | 4 +- .../deerflow/models/message_ordering.py | 37 +- .../harness/deerflow/models/patched_openai.py | 67 +- .../deerflow/persistence/models/__init__.py | 2 + .../persistence/parallel_agent_tasks/sql.py | 53 +- .../strategy_agent_roles/__init__.py | 24 + .../persistence/strategy_agent_roles/base.py | 39 + .../strategy_agent_roles/memory.py | 114 + .../persistence/strategy_agent_roles/model.py | 36 + .../persistence/strategy_agent_roles/sql.py | 141 + .../harness/deerflow/utils/stream_text.py | 39 +- .../harness/tests/test_message_ordering.py | 33 +- .../test_llm_error_handling_middleware.py | 100 +- .../tests/test_llmwiki_encoder_runtime.py | 283 ++ .../backend/tests/test_llmwiki_weknora.py | 123 + .../backend/tests/test_llmwiki_wiki_sync.py | 95 +- .../tests/test_roundtable_model_fallback.py | 520 +++ .../tests/test_sandbox_mount_config.py | 46 + .../tests/test_strategy_agent_management.py | 667 +++ .../tests/test_strategy_project_cas.py | 81 + .../tests/test_strategy_project_settings.py | 72 + .../tests/test_strategy_selection_seed.py | 276 ++ .../tests/test_summarization_human_message.py | 38 +- .../tests/test_wiki_package_roundtrip.py | 19 +- offline-backend-20260512/config.yaml | 27 + scripts/start-backend.sh | 4 +- 114 files changed, 14787 insertions(+), 451 deletions(-) create mode 100644 frontend-web/src/components/workspace/my-project-thread-indicator.tsx create mode 100644 frontend-web/src/core/threads/project-metadata.test.ts create mode 100644 frontend-web/src/core/threads/project-metadata.ts create mode 100644 frontend-web/src/strategy-selection/AgentConfigDialog.tsx create mode 100644 frontend-web/src/strategy-selection/StrategyProjectsPage.tsx create mode 100644 frontend-web/src/strategy-selection/StrategySelectionPage.tsx create mode 100644 frontend-web/src/strategy-selection/api.ts create mode 100644 frontend-web/src/strategy-selection/constants.ts create mode 100644 frontend-web/src/strategy-selection/lib.test.ts create mode 100644 frontend-web/src/strategy-selection/lib.ts create mode 100644 frontend-web/src/strategy-selection/project-routes.test.ts create mode 100644 frontend-web/src/strategy-selection/project-routes.ts create mode 100644 frontend-web/src/strategy-selection/project-wal.test.ts create mode 100644 frontend-web/src/strategy-selection/project-wal.ts create mode 100644 frontend-web/src/strategy-selection/projects.ts create mode 100644 frontend-web/src/strategy-selection/strategy-agent-mapping.ts create mode 100644 frontend-web/src/strategy-selection/strategy-agents.test.ts create mode 100644 frontend-web/src/strategy-selection/strategy-agents.ts create mode 100644 frontend-web/src/strategy-selection/types.ts create mode 100644 offline-backend-20260512/backend/app/gateway/llmwiki_index_runtime.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-diplomacy/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-diplomacy/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-economy/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-economy/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-governance/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-governance/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-law-policy/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-law-policy/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-public-opinion/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-public-opinion/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-security/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-angle-security/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-intent/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-intent/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-judge/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-judge/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-solution/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_strategy_selection_seed_assets/strategy-selection-solution/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/strategy_agents.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/strategy_agent_roles/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/strategy_agent_roles/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/strategy_agent_roles/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/strategy_agent_roles/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/strategy_agent_roles/sql.py create mode 100644 offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py create mode 100644 offline-backend-20260512/backend/tests/test_sandbox_mount_config.py create mode 100644 offline-backend-20260512/backend/tests/test_strategy_agent_management.py create mode 100644 offline-backend-20260512/backend/tests/test_strategy_project_cas.py create mode 100644 offline-backend-20260512/backend/tests/test_strategy_project_settings.py create mode 100644 offline-backend-20260512/backend/tests/test_strategy_selection_seed.py diff --git a/.gitignore b/.gitignore index da318d91..ba9949d8 100644 --- a/.gitignore +++ b/.gitignore @@ -100,11 +100,13 @@ offline-backend-20260512/backend/tests/* # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !offline-backend-20260512/backend/tests/test_model_management.py -# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned. -!offline-backend-20260512/backend/tests/test_reasoning_fields.py -# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. -!offline-backend-20260512/backend/tests/test_forced_research_agent.py -# Keep fixed-question exact-match and streaming regression coverage versioned. +# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned. +!offline-backend-20260512/backend/tests/test_reasoning_fields.py +# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. +!offline-backend-20260512/backend/tests/test_forced_research_agent.py +# Keep strategy-selection built-in agent seed and safety-contract regressions versioned. +!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py +# Keep fixed-question exact-match and streaming regression coverage versioned. !offline-backend-20260512/backend/tests/test_fixed_questions.py # Keep configurable es_query system-prompt routing regression coverage versioned. !offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py @@ -112,6 +114,7 @@ offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py !offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py +!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py !offline-backend-20260512/backend/tests/test_external_llmwiki_search.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py !offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py diff --git a/CLAUDE.md b/CLAUDE.md index ff99545b..755bf4dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. +- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`. diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 5077f8d4..ab23b6c7 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api # 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。 -VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx index 45f39694..cf44531f 100644 --- a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -51,6 +51,10 @@ import { SelectValue, } from "@/components/ui/select"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { useTheme } from "@/contexts/ThemeContext"; import { useAgentsPaginated } from "@/core/agents"; import { getAPIClient } from "@/core/api"; @@ -69,6 +73,7 @@ import { useToggleThreadPin, } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata"; import { bootstrapResumePath, isBootstrapThread, @@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { ) : ( filteredThreads.map((thread) => { const isBoot = isBootstrapThread(thread); + const projectType = myProjectConversationTypeOfThread(thread); const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const isActive = href === pathname; const pinned = isThreadPinned(thread); @@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { )} aria-label="智能体创建会话" /> + ) : projectType ? ( + ) : ( )} - + {desensitize(titleOfThread(thread, t.pages.newChat))} + {projectType && ( + + )} {isActive && ( )} diff --git a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx index 3bdf1204..3207c8a3 100644 --- a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx +++ b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx @@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { useThreadsPage } from "@/core/threads/hooks"; +import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata"; import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) { {threads.map((thread) => { const href = pathOfThread(thread); const isActive = href === pathname; + const projectType = myProjectConversationTypeOfThread(thread); return ( - - + {projectType ? ( + + ) : ( + + )} + {desensitize(titleOfThread(thread, "新对话"))} + {projectType && } ); })} diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index 43e6062d..2ff4ecba 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -60,6 +60,10 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { useAgentsPaginated, type Agent } from "@/core/agents"; import { getAPIClient } from "@/core/api"; import { useI18n } from "@/core/i18n/hooks"; @@ -75,6 +79,7 @@ import { useToggleThreadPin, } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata"; import { isBootstrapThread, isHiddenFromRecentChats, @@ -483,6 +488,7 @@ export function GeneralQaSidebar() { ); const isActive = href === pathname; const isBoot = isBootstrapThread(thread); + const projectType = myProjectConversationTypeOfThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -507,6 +513,8 @@ export function GeneralQaSidebar() { )} aria-label="智能体创建会话" /> + ) : projectType ? ( + ) : ( )} - + {desensitize(titleOfThread(thread, t.pages.newChat))} + {projectType && ( + + )} {isActive && ( )} diff --git a/frontend-web/src/components/workspace/model-name-select.tsx b/frontend-web/src/components/workspace/model-name-select.tsx index 327aaa63..7ee1afa4 100644 --- a/frontend-web/src/components/workspace/model-name-select.tsx +++ b/frontend-web/src/components/workspace/model-name-select.tsx @@ -25,6 +25,7 @@ export function ModelNameSelect({ className, triggerClassName, defaultLabel = "默认模型", + title = "选择用于本次操作的大模型(默认使用系统配置的模型)", }: { value: string | null; onChange: (modelName: string | null) => void; @@ -32,6 +33,7 @@ export function ModelNameSelect({ className?: string; triggerClassName?: string; defaultLabel?: string; + title?: string; }) { const { models } = useModels(); return ( @@ -42,7 +44,7 @@ export function ModelNameSelect({ > diff --git a/frontend-web/src/components/workspace/my-project-thread-indicator.tsx b/frontend-web/src/components/workspace/my-project-thread-indicator.tsx new file mode 100644 index 00000000..56af656a --- /dev/null +++ b/frontend-web/src/components/workspace/my-project-thread-indicator.tsx @@ -0,0 +1,51 @@ +import { FolderKanban } from "lucide-react"; + +import { + MY_PROJECT_CONVERSATION_LABELS, + type MyProjectConversationType, +} from "@/core/threads/project-metadata"; +import { cn } from "@/lib/utils"; + +const BADGE_STYLES: Record = { + qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300", + strategy: + "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300", + solution: + "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300", +}; + +export function MyProjectThreadIcon({ + type, + className, +}: { + type: MyProjectConversationType; + className?: string; +}) { + return ( + + ); +} + +export function MyProjectThreadBadge({ + type, + className, +}: { + type: MyProjectConversationType; + className?: string; +}) { + return ( + + {MY_PROJECT_CONVERSATION_LABELS[type]} + + ); +} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index f0bbb213..73ffd7cc 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -49,7 +49,11 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; -import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { getAPIClient } from "@/core/api"; import { useI18n } from "@/core/i18n/hooks"; import { @@ -63,7 +67,8 @@ import { useToggleThreadFavorite, useToggleThreadPin, } from "@/core/threads/hooks"; -import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata"; import { isBootstrapThread, isHiddenFromRecentChats, @@ -245,7 +250,8 @@ export function RecentChatList({ {shownThreads.map((thread) => { const href = listPathOfThread(thread); const isActive = href === pathname; - const isBoot = isBootstrapThread(thread); + const isBoot = isBootstrapThread(thread); + const projectType = myProjectConversationTypeOfThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -279,8 +285,10 @@ export function RecentChatList({ } aria-label="智能体创建会话" /> - ) : ( - compact && ( + ) : projectType ? ( + + ) : ( + compact && ( )} - - {desensitize(titleOfThread(thread, t.pages.newChat))} - + + {desensitize(titleOfThread(thread, t.pages.newChat))} + + {projectType && ( + + )} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( diff --git a/frontend-web/src/components/workspace/settings/settings-dialog.tsx b/frontend-web/src/components/workspace/settings/settings-dialog.tsx index e104cc64..c36018b5 100644 --- a/frontend-web/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend-web/src/components/workspace/settings/settings-dialog.tsx @@ -69,7 +69,7 @@ export function SettingsDialog(props: SettingsDialogProps) { // }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, ...(isAdmin - ? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }] + ? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }] : []), { id: "account", diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index bcd222ed..2f1d8484 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -14,7 +14,7 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; -import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; +import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { SidebarCollapseButton } from "./workspace-header"; @@ -24,7 +24,10 @@ import { applyCompactMenuOverrides, type CompactMenuItem, } from "@/core/page-layout/compact-menu-overrides"; -import { type MenuItem } from "@/core/page-layout/sidebar-menu"; +import { + MARKDOWN_DOCX_MENU_ITEM, + type MenuItem, +} from "@/core/page-layout/sidebar-menu"; import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides"; // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 @@ -65,6 +68,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ path: "/page/workspace/chats", match: (pathname) => pathname === "/page/workspace/chats", }, + { + id: "strategy-projects", + label: "我的项目", + icon: FolderKanban, + path: "/page/strategy/qa/projects", + match: (pathname) => + pathname.startsWith("/page/strategy/qa/projects") || + pathname.startsWith("/page/strategy/qa/strategy-selection"), + }, { id: "workspace-library", label: "资料库", @@ -140,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ label: "知识库管理", icon: BookOpen, path: "/page/workspace/knowledge", - match: (pathname) => pathname.startsWith("/page/workspace/knowledge"), + match: (pathname) => + pathname.startsWith("/page/workspace/knowledge") && + !pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path), + }, + { + ...MARKDOWN_DOCX_MENU_ITEM, + match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path), }, ]; diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index de466f3c..b223bcf2 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -21,6 +21,7 @@ import { FileBox, FileSearch, FileText, + FolderKanban, Gauge, Grid3X3, HelpCircle, @@ -48,12 +49,23 @@ export type MenuItem = { path?: string; /** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */ aliasPaths?: string[]; + /** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */ + excludePaths?: string[]; /** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */ userPath?: string; adminOnly?: boolean; children?: MenuItem[]; }; +/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */ +export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor"; +export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = { + id: "resource-markdown-docx-editor", + label: "md转word", + icon: FileText, + path: MARKDOWN_DOCX_MENU_PATH, +}; + export const sidebarMenuItems: MenuItem[] = [ { id: "qa-center", @@ -68,6 +80,13 @@ export const sidebarMenuItems: MenuItem[] = [ icon: Users, path: "/page/strategy/qa/multi-agent-roundtable", }, + { + id: "strategy-projects", + label: "我的项目", + icon: FolderKanban, + path: "/page/strategy/qa/projects", + aliasPaths: ["/page/strategy/qa/strategy-selection"], + }, // { // id: "position-roundtable", // label: "岗位会商", @@ -258,12 +277,7 @@ export const sidebarMenuItems: MenuItem[] = [ }, { id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" }, { id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, - { - id: "resource-markdown-docx-editor", - label: "md转word", - icon: FileText, - path: "/page/workspace/knowledge/markdown-editor", - }, + MARKDOWN_DOCX_MENU_ITEM, { id: "mgmt-tasks", label: "定时任务", @@ -330,6 +344,7 @@ export const sidebarMenuItems: MenuItem[] = [ label: "知识库管理", icon: BookOpen, path: "/page/workspace/knowledge", + excludePaths: [MARKDOWN_DOCX_MENU_PATH], }, { id: "resource-knowledge-base", @@ -365,6 +380,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean { /** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */ export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean { + if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) { + return false; + } if (routeMatchesPathname(pathname, item.path)) return true; return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false; } diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index e6f18342..d9d26e36 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -13,6 +13,44 @@ export type KnowledgeBaseProductVersion = "v1" | "v2"; export type OrdinaryKnowledgeLanding = "wiki" | "weknora"; +export interface StrategyProjectSettings { + taiwan_strait_demo_enabled: boolean; +} + +export interface StrategyProjectSettingsUpdate { + taiwan_strait_demo_enabled?: boolean; +} + +export async function loadStrategyProjectSettings(): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/strategy-projects`, + ); + if (!response.ok) { + throw new Error(`无法读取策略项目设置(HTTP ${response.status})`); + } + return (await response.json()) as StrategyProjectSettings; +} + +export async function updateStrategyProjectSettings( + body: StrategyProjectSettingsUpdate, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/strategy-projects`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const detail = await response + .json() + .catch(() => ({ detail: "保存策略项目设置失败" })); + throw new Error(detail.detail ?? "保存策略项目设置失败"); + } + return (await response.json()) as StrategyProjectSettings; +} + export async function loadOrdinaryKnowledgeLanding(): Promise { const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`); if (!response.ok) throw new Error("无法读取普通知识库默认页面设置"); diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 8e51b828..ac138de4 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -13,6 +13,7 @@ import { loadPromptPrefixSettings, loadPublishSquares, loadSkillCompressionSettings, + loadStrategyProjectSettings, updateAgentModuleConfig, updateAppearanceSettings, updateCitationDisplaySettings, @@ -23,6 +24,7 @@ import { updatePromptPrefixSettings, updatePublishSquares, updateSkillCompressionSettings, + updateStrategyProjectSettings, type AppearanceUpdate, type CitationDisplayUpdate, type LeaderboardSettingsUpdate, @@ -32,6 +34,7 @@ import { type PromptPrefixUpdate, type PublishSquaresUpdate, type SkillCompressionUpdate, + type StrategyProjectSettingsUpdate, type LayoutMode, type ViewMode, } from "./api"; @@ -45,6 +48,28 @@ const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const; const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const; const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const; +const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const; + +export function useStrategyProjectSettings() { + return useQuery({ + queryKey: STRATEGY_PROJECTS_KEY, + queryFn: () => loadStrategyProjectSettings(), + staleTime: 60_000, + refetchOnWindowFocus: false, + retry: false, + }); +} + +export function useUpdateStrategyProjectSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: StrategyProjectSettingsUpdate) => + updateStrategyProjectSettings(body), + onSuccess: (data) => { + queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data); + }, + }); +} export function usePromptPrefixSettings() { return useQuery({ diff --git a/frontend-web/src/core/threads/project-metadata.test.ts b/frontend-web/src/core/threads/project-metadata.test.ts new file mode 100644 index 00000000..74be85e7 --- /dev/null +++ b/frontend-web/src/core/threads/project-metadata.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + myProjectConversationTypeFromSearchParams, + myProjectConversationTypeOfThread, + myProjectThreadMetadata, + withMyProjectConversationMarker, +} from "./project-metadata"; + +test("project conversation type is read from persisted metadata", () => { + assert.equal( + myProjectConversationTypeOfThread({ + metadata: myProjectThreadMetadata("strategy"), + }), + "strategy", + ); + assert.equal( + myProjectConversationTypeOfThread({ + metadata: { project_source: "elsewhere", project_type: "qa" }, + }), + null, + ); +}); + +test("metadata wins over the compatibility context fallback", () => { + assert.equal( + myProjectConversationTypeOfThread({ + metadata: myProjectThreadMetadata("solution"), + context: myProjectThreadMetadata("qa"), + }), + "solution", + ); +}); + +test("new-chat route marker round-trips all supported project types", () => { + for (const type of ["qa", "strategy", "solution"] as const) { + const path = withMyProjectConversationMarker( + "/page/strategy/chats/new?keep=1", + type, + ); + const params = new URLSearchParams(path.split("?", 2)[1]); + assert.equal(params.get("keep"), "1"); + assert.equal(myProjectConversationTypeFromSearchParams(params), type); + } +}); diff --git a/frontend-web/src/core/threads/project-metadata.ts b/frontend-web/src/core/threads/project-metadata.ts new file mode 100644 index 00000000..a7bef103 --- /dev/null +++ b/frontend-web/src/core/threads/project-metadata.ts @@ -0,0 +1,94 @@ +/** + * Stable marker for conversations entered from 「我的项目」. + * + * Keep this separate from ``project_id``: that key belongs to DeerFlow's + * folder-style conversation projects, while this marker describes the product + * mode that created (or explicitly continued) a conversation. + */ +export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const; + +export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const; +export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const; + +export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const; +export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const; + +export type MyProjectConversationType = "qa" | "strategy" | "solution"; + +export const MY_PROJECT_CONVERSATION_LABELS: Record< + MyProjectConversationType, + string +> = { + qa: "普通问答", + strategy: "策略优选", + solution: "方案生成", +}; + +const MY_PROJECT_CONVERSATION_TYPES = new Set([ + "qa", + "strategy", + "solution", +]); + +function typeFromRecord( + value: Record | null | undefined, +): MyProjectConversationType | null { + if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) { + return null; + } + const type = value[MY_PROJECT_THREAD_TYPE_KEY]; + return typeof type === "string" && + MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType) + ? (type as MyProjectConversationType) + : null; +} + +export function myProjectThreadMetadata( + type: MyProjectConversationType, +): Record { + return { + [MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE, + [MY_PROJECT_THREAD_TYPE_KEY]: type, + }; +} + +/** Read persisted metadata first; context is only a compatibility fallback. */ +export function myProjectConversationTypeOfThread(thread: { + metadata?: Record | null; + context?: Record | null; +}): MyProjectConversationType | null { + return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context); +} + +export function myProjectConversationTypeFromSearchParams(params: { + get(name: string): string | null; +}): MyProjectConversationType | null { + if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) { + return null; + } + const type = params.get(MY_PROJECT_QUERY_TYPE_KEY); + return typeof type === "string" && + MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType) + ? (type as MyProjectConversationType) + : null; +} + +/** Add an explicit, one-shot marker to a new-conversation route. */ +export function withMyProjectConversationMarker( + path: string, + type: MyProjectConversationType, +): string { + const hashIndex = path.indexOf("#"); + const hash = hashIndex >= 0 ? path.slice(hashIndex) : ""; + const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path; + const queryIndex = pathWithoutHash.indexOf("?"); + const pathname = + queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash; + const params = new URLSearchParams( + queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "", + ); + params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE); + params.set(MY_PROJECT_QUERY_TYPE_KEY, type); + const search = params.toString(); + return `${pathname}${search ? `?${search}` : ""}${hash}`; +} diff --git a/frontend-web/src/eightbf-roundtable/api/task-context.ts b/frontend-web/src/eightbf-roundtable/api/task-context.ts index 5e964092..a4e31e3f 100644 --- a/frontend-web/src/eightbf-roundtable/api/task-context.ts +++ b/frontend-web/src/eightbf-roundtable/api/task-context.ts @@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt { id: string; } +/** + * 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1, + * 用于与岗位会商等其它入口的产物区分来源。 + */ +function processReportBody(input: EightBfTaskProcessReportInput): string { + return JSON.stringify({ ...input, eightTask: 1 }); +} + /** * 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用 * `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。 @@ -180,7 +188,7 @@ export async function createEightBfTaskProcessReport( const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), + body: processReportBody(input), }); if (!response.ok) throw await externalTaskError(response, "过程产物登记失败"); const id = findProcessReportId(await response.json().catch(() => null)); @@ -198,7 +206,7 @@ export async function updateEightBfTaskProcessReport( { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), + body: processReportBody(input), }, ); if (!response.ok) throw await externalTaskError(response, "过程产物提交失败"); diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index 52ddb987..af65d3e7 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -88,14 +88,29 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa () => buildEightBfTaskContext(task, taskDictionaries), [task, taskDictionaries], ); + // 席位「产物提交 position」映射:业务链里逐席位配置的 artifact_position(agent_id → 值)。 + // 提交产物时按产出该产物的席位智能体取值;未配置的席位不进 map(提交时回退默认 0)。 + const artifactPositionByAgent = useMemo(() => { + const map = new Map(); + for (const seat of configuredChain?.seats ?? []) { + if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) { + map.set(seat.agent_id, seat.artifact_position); + } + } + return map; + }, [configuredChain]); const taskArtifactSubmissionConfig = useMemo( () => taskId ? { taskId, position: 0, + positionFor: (target) => { + const agentId = target.agentId?.trim(); + return agentId ? artifactPositionByAgent.get(agentId) ?? null : null; + }, createProcessReport: createEightBfTaskProcessReport, updateProcessReport: updateEightBfTaskProcessReport, } : undefined, - [taskId], + [taskId, artifactPositionByAgent], ); useEffect(() => { diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 5edcc89f..8388e12f 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -64,6 +64,10 @@ import { useThreadSettings, } from "@/core/settings"; import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; +import { + myProjectConversationTypeFromSearchParams, + myProjectThreadMetadata, +} from "@/core/threads/project-metadata"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; @@ -286,16 +290,28 @@ export default function ChatPage() { }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]); const { showNotification } = useNotification(); + const projectConversationType = + myProjectConversationTypeFromSearchParams(searchParams); + const newThreadMetadata = useMemo | undefined>(() => { + const metadata: Record = projectConversationType + ? myProjectThreadMetadata(projectConversationType) + : {}; + if (knowledgeSelectionReady) { + metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds; + metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds; + } + return Object.keys(metadata).length > 0 ? metadata : undefined; + }, [ + knowledgeSelectionReady, + projectConversationType, + selectedAssistantKnowledgeBaseIds, + selectedKnowledgeBaseIds, + ]); const [thread, sendMessage, isUploading] = useThreadStream({ threadId: isNewThread ? undefined : threadId, context: streamContext, - threadMetadata: knowledgeSelectionReady - ? { - llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds, - assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds, - } - : undefined, + threadMetadata: newThreadMetadata, isMock, onStart: (createdThreadId) => { knowledgeSelectionThread.current = createdThreadId; @@ -462,7 +478,9 @@ export default function ChatPage() { try { let targetThreadId = threadId; if (isNewThread) { - const created = await getAPIClient(isMock).threads.create({}); + const created = await getAPIClient(isMock).threads.create({ + metadata: newThreadMetadata ?? {}, + }); targetThreadId = created.thread_id; setThreadId(targetThreadId); setIsNewThread(false); @@ -522,13 +540,8 @@ export default function ChatPage() { try { const created = await getAPIClient(isMock).threads.create({ metadata: { + ...newThreadMetadata, agent_id: selectedAgentId, - ...(knowledgeSelectionReady - ? { - llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds, - assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds, - } - : {}), }, }); sessionStorage.setItem( @@ -547,7 +560,7 @@ export default function ChatPage() { ); saveLastAgentId(selectedAgentId); router.push( - `/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`, + `${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`, ); } catch (err) { agentHandoffPendingRef.current = false; @@ -606,6 +619,7 @@ export default function ChatPage() { selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, knowledgeSelectionReady, + newThreadMetadata, agentHandoffPending, isMock, router, diff --git a/frontend-web/src/pages/OfficialDeerFlowEntry.tsx b/frontend-web/src/pages/OfficialDeerFlowEntry.tsx index fa1df75d..3945cae9 100644 --- a/frontend-web/src/pages/OfficialDeerFlowEntry.tsx +++ b/frontend-web/src/pages/OfficialDeerFlowEntry.tsx @@ -5,6 +5,7 @@ import { ChevronDown, CircleHelp, Clock3, + FolderKanban, LoaderCircle, MessageSquareText, PanelLeftClose, @@ -20,7 +21,12 @@ import { } from "lucide-react"; import { type FormEvent, useCallback, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; +import { Toaster } from "sonner"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { useAgent, useAgents } from "@/core/agents"; import { getAccountDisplayName } from "@/core/auth"; import { @@ -29,28 +35,41 @@ import { useScheduledTaskRuns, useScheduledTasks, } from "@/core/scheduled-tasks"; -import { useLocalSettings } from "@/core/settings"; import { useAppearanceSettings, useCitationDisplaySettings, } from "@/core/system-settings/hooks"; import { useThreadStream, useThreads } from "@/core/threads/hooks"; +import { + myProjectConversationTypeOfThread, + myProjectConversationTypeFromSearchParams, + myProjectThreadMetadata, +} from "@/core/threads/project-metadata"; import { textOfMessage, titleOfThread } from "@/core/threads/utils"; import { uuid } from "@/core/utils/uuid"; import { OFFICIAL_DEERFLOW_HOME_ROUTE, OFFICIAL_DEERFLOW_ROUTE_PREFIX, } from "@/core/official-deerflow/routes"; +import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage"; +import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage"; import "./official-deerflow-entry.css"; -type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings"; +type OfficialPage = + | "chat" + | "projects" + | "agents" + | "agent-chat" + | "scheduled" + | "settings"; function pageFromPath(pathname: string): OfficialPage { const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length); if (path.startsWith("/workspace/agents/") && path.includes("/chats")) { return "agent-chat"; } + if (path.startsWith("/workspace/projects")) return "projects"; if (path.startsWith("/workspace/agents")) return "agents"; if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled"; if (path.startsWith("/workspace/settings")) return "settings"; @@ -78,6 +97,11 @@ function parseScheduledTaskId(pathname: string) { return match ? decodeURIComponent(match[1]!) : null; } +function parseProjectId(pathname: string) { + const match = pathname.match(/\/workspace\/projects\/([^/]+)/); + return match ? decodeURIComponent(match[1]!) : null; +} + function readableError(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -159,20 +183,36 @@ function ChatWorkspace({ routeThreadId: string; }) { const navigate = useNavigate(); + const location = useLocation(); const [prompt, setPrompt] = useState(""); const [sendError, setSendError] = useState(null); const isNewThread = routeThreadId === "new"; + const projectConversationType = isNewThread + ? myProjectConversationTypeFromSearchParams( + new URLSearchParams(location.search), + ) + : null; // A new conversation uses the same client-generated id approach as the // existing ChatPage. The backend owns the persisted id once streaming starts. const [draftThreadId] = useState(() => uuid() as string); const activeThreadId = isNewThread ? draftThreadId : routeThreadId; const { agent } = useAgent(agentId, { enabled: !!agentId }); - const [localSettings] = useLocalSettings(); const [thread, sendMessage, isUploading] = useThreadStream({ threadId: isNewThread ? undefined : routeThreadId, assistantId: agentId ?? undefined, + threadMetadata: projectConversationType + ? myProjectThreadMetadata(projectConversationType) + : undefined, context: { - ...localSettings.context, + // This compact official shell has no model/mode controls of its own. + // Do not inherit a hidden selection saved by the customized workspace + // (for example an obsolete or quota-exhausted model); an omitted model + // lets the backend resolve the current system default or the agent's + // globally configured model. + model_name: undefined, + mode: undefined, + reasoning_effort: undefined, + writing_mode: false, ...(agentId ? { agent_id: agentId } : {}), }, onStart: (createdThreadId) => { @@ -658,6 +698,10 @@ export default function OfficialDeerFlowEntry() { () => parseScheduledTaskId(location.pathname), [location.pathname], ); + const projectId = useMemo( + () => parseProjectId(location.pathname), + [location.pathname], + ); const { data: threads = [], isLoading: threadsLoading, error: threadsError } = useThreads({ limit: 20, @@ -666,10 +710,12 @@ export default function OfficialDeerFlowEntry() { select: ["thread_id", "updated_at", "values", "metadata"], }); const go = (path: string) => navigate(path); + const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`; const nav = [ { icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] }, { icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] }, + { icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] }, { icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] }, { icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] }, ] as const; @@ -706,29 +752,43 @@ export default function OfficialDeerFlowEntry() { ) : threadsError ? (

对话列表加载失败

) : threads.length ? ( - threads.map((chat) => ( - - )) + threads.map((chat) => { + const projectType = myProjectConversationTypeOfThread(chat); + return ( + + ); + }) ) : (

暂无历史对话

)} @@ -754,6 +814,17 @@ export default function OfficialDeerFlowEntry() { routeThreadId={chatRoute.threadId} /> )} + {page === "projects" && ( + projectId ? ( + + ) : ( + + ) + )} {page === "agents" && ( @@ -771,6 +842,7 @@ export default function OfficialDeerFlowEntry() { {page === "scheduled" && } {page === "settings" && } + ); } diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index e0df543d..d32b238c 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -1,4 +1,4 @@ -import { Navigate, Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes, useParams } from "react-router-dom"; import { Toaster } from "sonner"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; @@ -78,6 +78,8 @@ import UsersPage from "./UsersPage"; import LlmMetricsPage from "./LlmMetricsPage"; import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminUserActivityPage from "./AdminUserActivityPage"; +import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage"; +import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage"; import { ChatRuntime } from "./WorkspaceRoutes"; /** @@ -98,6 +100,11 @@ function StrategyChatThreadLayout() { ); } +function StrategyProjectWorkbenchRoute() { + const { project_id: projectId = "" } = useParams<{ project_id: string }>(); + return ; +} + export default function StrategyRoutes() { const storedAuth = getStoredAuth(); const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin"; @@ -110,6 +117,20 @@ export default function StrategyRoutes() { } /> } /> + + } + /> + } + /> + } + /> p { color: #90908e; font-size: 12px; margin: 0 8px 9px; } .official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; } .official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.official-recent-chat-copy { align-items: center; display: flex; gap: 6px; min-width: 0; } +.official-recent-chat-title { flex: 1; min-width: 0; } .official-recent-chat small { color: #acaca9; font-size: 11px; } .official-recent-chat.is-active { background: #efefed; color: #252525; } .official-sidebar-loading, diff --git a/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts b/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts index ce413a3c..2088894a 100644 --- a/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts +++ b/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts @@ -55,12 +55,14 @@ function payloadFrom( ): TaskProcessReportPayload { const name = target.name?.trim() || fileName(target.path); const agentId = target.agentId?.trim() || target.threadId; + // 产物提交 position:优先取业务链里给该席位智能体配置的值,未配置回退默认。 + const position = config.positionFor?.(target) ?? config.position; return { taskId: config.taskId, agentId, agentName: target.agentName?.trim() || agentId, content, - position: config.position, + position, reportName: name, submitStatus, }; diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index c974ebd6..cbd01dfb 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -55,6 +55,13 @@ export interface ChainSeat { * 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。 */ position_id?: string | null; + /** + * 该席位提交过程产物时传给外部任务系统的 `position` 值(8BF 会商页用)。 + * 不同席位智能体对应不同的产物位置,缺省 / null = 提交时回退 0。 + * 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明)。 + * 注意与 `position_id`(岗位协同的岗位归属)无关。 + */ + artifact_position?: number | null; /** * 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示; * 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。 diff --git a/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts b/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts index de257493..ea030d8a 100644 --- a/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts +++ b/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts @@ -13,7 +13,14 @@ export interface TaskProcessReportPayload { export interface TaskArtifactSubmissionConfig { /** External task-system id, not the local roundtable draft storage key. */ taskId: string; + /** 提交产物时传给外部任务系统的默认 position(产物位置)。 */ position: number; + /** + * 按席位智能体解析提交产物的 position(业务链里逐席位配置的「产物提交 position」)。 + * 入参是 hook 解析后的 target(`agentId` 已归一为席位 agent_id);返回 + * null / undefined = 该席位未配置,回退 `position` 默认值。 + */ + positionFor?: (target: TaskArtifactTarget) => number | null | undefined; createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>; updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise; } diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 680a5db8..04640d87 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -697,6 +697,8 @@ export default function BusinessChainEditorPage() { seat_mode: s.seat_mode ?? null, // 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。 position_id: s.position_id ?? null, + // 席位「产物提交 position」:提交过程产物时传给外部任务系统;null = 回退默认 0。 + artifact_position: s.artifact_position ?? null, // 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。 job_responsibilities: s.job_responsibilities?.trim() || null, }; diff --git a/frontend-web/src/strategy-components/Header.tsx b/frontend-web/src/strategy-components/Header.tsx index baf95239..ef3d182f 100644 --- a/frontend-web/src/strategy-components/Header.tsx +++ b/frontend-web/src/strategy-components/Header.tsx @@ -41,10 +41,6 @@ const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomThe : "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]", ); -// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理) -const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"]; -const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"]; - type HeaderProps = { collapsed?: boolean; }; @@ -122,8 +118,11 @@ const Header = ({ collapsed }: HeaderProps) => { const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname); - const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id)); - const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id)); + // 深色顶栏围绕中间标题分成左右两组。直接按菜单管理后的一级顺序均分,确保被升为 + // 一级或重新排序的节点仍会显示;默认 5 项时仍保持左 3 / 右 2 的原布局。 + const topMenuSplitIndex = Math.ceil(visibleMenuItems.length / 2); + const leftMenuItems = visibleMenuItems.slice(0, topMenuSplitIndex); + const rightMenuItems = visibleMenuItems.slice(topMenuSplitIndex); const handleNavItemClick = (item: MenuItem) => { const path = item.path ?? findFirstRoutePath(item.children); diff --git a/frontend-web/src/strategy-components/api/llmwiki.ts b/frontend-web/src/strategy-components/api/llmwiki.ts index fb5ced79..0bc3306d 100644 --- a/frontend-web/src/strategy-components/api/llmwiki.ts +++ b/frontend-web/src/strategy-components/api/llmwiki.ts @@ -73,6 +73,21 @@ export interface LlmWikiKnowledgeBase { is_conversation_deposit?: boolean; } +export interface LlmWikiEmbeddingModel { + id: string; + name: string; + provider?: string | null; + model_name?: string | null; + status?: string | null; + is_default: boolean; + available: boolean; +} + +export interface LlmWikiEmbeddingModelsResponse { + models: LlmWikiEmbeddingModel[]; + default_model_id?: string | null; +} + export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase { wiki: { status: "ready" | "empty" | "unavailable"; @@ -268,6 +283,9 @@ export interface LlmWikiWikiIndexStatus { export interface LocalWikiEncoderStatus { provider: string; + configured_provider?: string | null; + mode?: "configured" | "local"; + using_local?: boolean; model: string; dimensions?: number | null; state: "idle" | "starting" | "ready" | "failed"; @@ -424,6 +442,17 @@ export async function getLlmWikiKnowledgeBase( ); } +export async function listLlmWikiEmbeddingModels(): Promise { + const payload = await readJson( + await apiFetch(`${API()}/embedding-models`), + "读取知识服务向量模型", + ); + return { + models: payload.models ?? [], + default_model_id: payload.default_model_id ?? null, + }; +} + export async function createLlmWikiWeKnoraEmbedSession( id: string, tab: "documents" | "wiki" | "graph", @@ -457,6 +486,7 @@ export async function createLlmWikiKnowledgeBase(input: { description: string; type: "document" | "faq"; wiki_enabled?: boolean; + embedding_model_id?: string; }): Promise { return readJson( await apiFetch(`${API()}/knowledge-bases`, { @@ -654,6 +684,13 @@ export async function configureLocalWikiEncoder( ); } +export async function restoreConfiguredWikiEncoder(): Promise { + return readJson( + await apiFetch(`${API()}/local-encoder/use-configured`, { method: "POST" }), + "恢复配置文件编码器", + ); +} + export async function uploadLocalWikiEncoder( files: File[], onProgress?: (percent: number) => void, @@ -688,10 +725,15 @@ export async function uploadLocalWikiEncoder( payload = null; } if (request.status < 200 || request.status >= 300) { - const detail = payload && typeof payload === "object" && "detail" in payload + const rawDetail = payload && typeof payload === "object" && "detail" in payload ? (payload as { detail: unknown }).detail : `请求失败 (${request.status})`; - reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail))); + const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail + ? String((rawDetail as { message: unknown }).message) + : typeof rawDetail === "string" + ? rawDetail + : JSON.stringify(rawDetail); + reject(new Error(userFacingKnowledgeMessage(detail))); return; } onProgress?.(100); @@ -1268,6 +1310,17 @@ export function useLlmWikiSettings(enabled = true) { }); } +export function useLlmWikiEmbeddingModels(enabled = true) { + const { userId } = useLiveAccount(); + return useQuery({ + queryKey: ["llmwiki", "embedding-models", userId ?? "anonymous"], + queryFn: listLlmWikiEmbeddingModels, + enabled, + staleTime: 60_000, + refetchOnMount: "always", + }); +} + export function useLlmWikiActions() { const queryClient = useQueryClient(); const invalidateKnowledge = () => diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx index f2973b98..cea79654 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace.tsx @@ -34,6 +34,13 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { getStoredAuth } from "@/core/auth"; @@ -48,7 +55,9 @@ import { searchLlmWiki, startLocalWikiEncoder, uploadLocalWikiEncoder, + restoreConfiguredWikiEncoder, useLlmWikiActions, + useLlmWikiEmbeddingModels, useLlmWikiKnowledgeBases, useLlmWikiSettings, useLlmWikiSettingsActions, @@ -68,6 +77,14 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : "操作失败,请稍后重试"; } +function encoderProviderLabel(provider?: string | null): string { + const normalized = provider?.trim().toLowerCase(); + if (normalized === "local") return "本地 BGE-M3"; + if (normalized === "openai") return "OpenAI"; + if (normalized === "openai_compatible") return "OpenAI 兼容服务"; + return provider?.trim() || "未配置"; +} + function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) { if (mode === "vector") return "本地 Wiki 向量检索"; if (mode === "mixed") return "Wiki 混合检索"; @@ -457,18 +474,50 @@ function CreateKnowledgeDialog({ open: boolean; pending: boolean; onOpenChange: (value: boolean) => void; - onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise; + onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean; embedding_model_id: string }) => Promise; }) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [wikiEnabled, setWikiEnabled] = useState(true); + const [embeddingModelId, setEmbeddingModelId] = useState(""); + const embeddingModels = useLlmWikiEmbeddingModels(open); + const availableEmbeddingModels = useMemo( + () => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false), + [embeddingModels.data?.models], + ); + useEffect(() => { - if (!open) { setName(""); setDescription(""); setWikiEnabled(true); } + if (!open) { + setName(""); + setDescription(""); + setWikiEnabled(true); + setEmbeddingModelId(""); + } }, [open]); + + useEffect(() => { + if (!open || !embeddingModels.data || availableEmbeddingModels.length === 0) return; + if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return; + const defaultId = embeddingModels.data.default_model_id; + setEmbeddingModelId( + availableEmbeddingModels.some((model) => model.id === defaultId) + ? defaultId! + : availableEmbeddingModels[0]!.id, + ); + }, [availableEmbeddingModels, embeddingModelId, embeddingModels.data, open]); + + const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => { + const provider = model.provider?.trim(); + const implementation = model.model_name?.trim(); + const suffix = [provider, implementation].filter(Boolean).join(" · "); + return suffix ? `${model.name}(${suffix})` : model.name; + }; + const modelSelectionUnavailable = embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId; + return ( - 新建个人知识库选择知识库模式;模型、切块和存储策略仍由知识服务管理。 + 新建个人知识库选择知识库模式和知识服务用于原文分块的向量模型。
setName(event.target.value)} maxLength={255} />