Compare commits

..
37 Commits
Author SHA1 Message Date
吉鑫升 ca2696b23f Merge branch 'master' into master-zl 2026-08-31 18:06:54 +08:00
吉鑫升 2718915d34 Merge branch 'master' into master-zl 2026-08-31 18:02:26 +08:00
吉鑫升 ee83258b70 Merge branch 'master' into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
#	offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
2026-08-31 17:55:11 +08:00
吉鑫升 87505feac4 feat: improve skill uploads and agent runtime stability 2026-08-27 11:15:25 +08:00
吉鑫升 c5eefe60f0 feat: standardize Word export and Office conversion 2026-08-27 00:09:08 +08:00
董宏凯 5be21f2c3d Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/agents/agent-card.tsx
#	frontend-web/src/components/workspace/custom-prompt-menu.tsx
2026-08-26 17:45:39 +08:00
吉鑫升 d488e65e49 Merge branch 'master' into master-zl 2026-08-26 16:09:06 +08:00
fengxiangdebaozi dcc3e0799d Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-26 12:31:48 +08:00
fengxiangdebaozi 7cc6aca398 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 17:36:14 +08:00
fengxiangdebaozi dd1064b3cb Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 17:07:09 +08:00
吉鑫升 be9a490923 fix: harden wiki delivery and leaderboard performance 2026-08-25 16:58:58 +08:00
fengxiangdebaozi 2ea331f9ec Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 14:53:57 +08:00
fengxiangdebaozi 296974e9e5 Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 14:28:45 +08:00
吉鑫升 ed5b550ead fix: restore embedded wiki images and optimize vector sync 2026-08-25 11:27:02 +08:00
fengxiangdebaozi 7f2c51957b Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 11:26:24 +08:00
吉鑫升 644cabd23d feat: complete public wiki and embedded previews 2026-08-24 20:42:39 +08:00
fengxiangdebaozi c4d61288b4 Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
2026-08-24 17:18:39 +08:00
fengxiangdebaozi 389df2ace5 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/components/workspace/skill-display-sources.tsx
2026-08-24 17:14:46 +08:00
吉鑫升 32dbdca480 Merge branch 'ji' into master-zl 2026-08-24 16:27:53 +08:00
fengxiangdebaozi 28eb8f86a7 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-24 14:45:45 +08:00
fengxiangdebaozi b3aa9f4bac Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-24 09:10:20 +08:00
fengxiangdebaozi 1c0e4e9723 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-21 21:48:29 +08:00
fengxiangdebaozi d19d6ced66 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-20 17:05:41 +08:00
fengxiangdebaozi 8e77e2da1e Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-19 21:24:58 +08:00
fengxiangdebaozi bc49ef6027 merge: integrate master-nj RAG updates 2026-08-19 20:46:37 +08:00
fengxiangdebaozi 288ba64923 代码提交 2026-08-19 20:39:06 +08:00
fengxiangdebaozi db5b6ba574 fix(rag): restore single-round source streaming 2026-08-19 20:29:00 +08:00
fengxiangdebaozi 1f961ee1df fix: bundle workspace branding assets 2026-08-18 19:43:35 +08:00
fengxiangdebaozi 6ff04ea4e3 fix: restore skill list styling 2026-08-18 16:50:27 +08:00
fengxiangdebaozi 0f667261be Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/skill-display-sources.tsx
2026-08-18 16:43:48 +08:00
fengxiangdebaozi e412d7db3f 代码合并 2026-08-18 16:39:56 +08:00
吉鑫升 6de3026f8f fix: adapt WeKnora source preview on master-zl 2026-08-18 15:48:58 +08:00
吉鑫升 64067ea478 Merge branch 'ji' into master-zl 2026-08-18 15:38:17 +08:00
fengxiangdebaozi e0b6e1cd19 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/App.tsx
#	frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
#	frontend-web/src/components/workspace/agents/agent-gallery.tsx
#	frontend-web/src/components/workspace/appearance-controls.tsx
#	frontend-web/src/components/workspace/compact-welcome.tsx
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/components/workspace/settings/appearance-settings-page.tsx
#	frontend-web/src/components/workspace/settings/skill-settings-page.tsx
#	frontend-web/src/components/workspace/skill-display-sources.tsx
#	frontend-web/src/components/workspace/workspace-sidebar.tsx
#	frontend-web/src/contexts/ThemeContext.tsx
#	frontend-web/src/core/embed/embed-session.ts
#	frontend-web/src/core/i18n/locales/en-US.ts
#	frontend-web/src/core/i18n/locales/types.ts
#	frontend-web/src/core/i18n/locales/zh-CN.ts
#	frontend-web/src/core/system-settings/api.ts
#	frontend-web/src/env.ts
#	frontend-web/src/pages/ChatPage.tsx
#	frontend-web/src/pages/LightAppIframeView.tsx
#	frontend-web/src/pages/LightAppListPage.tsx
#	frontend-web/src/pages/LightAppNoteQAPage.tsx
#	frontend-web/src/pages/NotebookAIWritingPage.tsx
#	frontend-web/src/pages/PageRoutes.tsx
#	frontend-web/src/pages/WorkspaceRoutes.tsx
#	frontend-web/src/pages/light-app-management.css
#	frontend-web/src/pages/light-app-pages.css
#	frontend-web/src/strategy-components/lib/light-app.ts
#	frontend-web/src/styles/globals.css
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/README.md
#	offline-backend-20260512/backend/app/gateway/routers/system_settings.py
#	offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
2026-08-18 10:06:34 +08:00
董宏凯 e2041c0b07 feat(frontend): add theme variants and compact navigation config 2026-07-22 14:55:42 +08:00
董宏凯 2fa7000ff7 feat(workspace): add red theme assets and adapt workspace styles 2026-07-19 17:30:38 +08:00
董宏凯 760b88d6f1 feat: refresh workspace UI and agent visuals 2026-07-16 00:06:40 +08:00
338 changed files with 3928 additions and 55642 deletions

No files matched your search

+26 -48
View File
@@ -87,61 +87,39 @@ offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py
# 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 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.
!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
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
!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_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py
# 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 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.
!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
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
!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_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
# Roundtable coordinator/seat capability isolation regressions.
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
!offline-backend-20260512/backend/tests/test_workflow_engine.py
!offline-backend-20260512/backend/tests/test_workflow_runs.py
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
# Writing-mode first-report pipeline enforcement regressions.
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
# LLM call timeout retry classification regression coverage.
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
# Writing-mode pipeline conversation-material regressions.
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
deploy
deploy
.history
# Cloned third-party repos (local only)
-2
View File
@@ -80,8 +80,6 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `AGENTS.md`/`CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
### Frontend
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
-2
View File
@@ -73,8 +73,6 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
### Frontend
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
@@ -5,9 +5,6 @@ const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
let pollTimer = null;
let isPolling = false;
let startupPromise = null;
const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]);
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -15,14 +12,6 @@ function delay(ms) {
async function getSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
const discovered = await chrome.storage.local.get({
discoveredApiBaseUrl: "",
discoveredWebBaseUrl: "",
});
if (settings.autoDiscoverServiceUrl) {
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl;
}
const updates = {};
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
@@ -80,7 +69,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
}
};
add(currentApiBase);
add(webBase);
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
add(LEGACY_LOCAL_API_BASE_URL);
try {
const url = new URL(normalizeServiceBase(webBase));
@@ -93,10 +85,6 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
// Ignore invalid candidates.
}
add(currentApiBase);
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
add(LEGACY_LOCAL_API_BASE_URL);
return candidates;
}
@@ -105,12 +93,6 @@ async function browserContextStatus(apiBaseUrl) {
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
credentials: "include",
});
// The gateway may enforce authentication globally before this otherwise
// public discovery endpoint is reached. A 401/403 still proves that this
// candidate is the zncm API paired with the open frontend tab.
if (response.status === 401 || response.status === 403) {
return { enabled: true, authenticationRequired: true };
}
if (!response.ok) {
return null;
}
@@ -219,12 +201,9 @@ async function discoverServiceBases(settings, force = false) {
settings.webBaseUrl !== next.webBaseUrl ||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
) {
// Discovered endpoints are runtime state, not user-authored preferences.
// Keep them in storage.local so Chrome sync write quotas cannot break task
// polling. The options page may still persist explicit user choices.
await chrome.storage.local.set({
discoveredApiBaseUrl: next.apiBaseUrl,
discoveredWebBaseUrl: next.webBaseUrl,
await chrome.storage.sync.set({
apiBaseUrl: next.apiBaseUrl,
webBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
});
@@ -847,50 +826,19 @@ async function pollOnce() {
}
}
async function initializePolling() {
async function startup() {
let settings = await getSettings();
try {
settings = await discoverServiceBases(settings);
} catch (error) {
// Service discovery persists diagnostic metadata in storage.sync. Chrome can
// temporarily reject those writes after its quota is reached; polling must
// still start with the last known settings.
console.warn("zncm service discovery failed during startup", error);
}
try {
settings = await syncAuthToken(settings);
} catch (error) {
// A failed status/token write must not prevent alarms from being installed.
// pollOnce() will retry token synchronization before claiming each task.
console.warn("zncm token sync failed during startup", error);
}
settings = await discoverServiceBases(settings);
settings = await syncAuthToken(settings);
await refreshAlarm(settings);
await scheduleNextPoll(1000);
}
function startup() {
if (!startupPromise) {
startupPromise = initializePolling().finally(() => {
startupPromise = null;
});
}
return startupPromise;
}
async function restartPollingSchedule() {
const settings = await getSettings();
await refreshAlarm(settings);
await scheduleNextPoll(0);
}
chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
chrome.runtime.onInstalled.addListener(() => void startup());
chrome.runtime.onStartup.addListener(() => void startup());
chrome.storage.onChanged.addListener((changes, areaName) => {
if (
areaName === "sync" &&
Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key))
) {
void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error));
if (areaName === "sync") {
void startup();
}
});
chrome.alarms.onAlarm.addListener((alarm) => {
@@ -954,4 +902,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
});
void startup().catch((error) => console.warn("zncm startup failed", error));
void startup();
@@ -1,130 +0,0 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const BACKGROUND_PATH = path.join(__dirname, "background.js");
function createHarness() {
let storageChangedListener;
let alarmCreateCount = 0;
const settings = {
apiBaseUrl: "http://localhost:8001",
webBaseUrl: "http://localhost:5174",
autoDiscoverServiceUrl: false,
accessToken: "",
tokenSyncEnabled: false,
taskPollingEnabled: true,
workerId: "test-worker",
taskPollIntervalSeconds: 5,
};
const chrome = {
alarms: {
clear: async () => {},
create: async () => {
alarmCreateCount += 1;
},
onAlarm: { addListener: () => {} },
},
cookies: { get: async () => null },
runtime: {
onInstalled: { addListener: () => {} },
onMessage: { addListener: () => {} },
onStartup: { addListener: () => {} },
},
storage: {
local: {
get: async (defaults) => ({ ...defaults }),
set: async () => {},
},
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
sync: {
get: async () => ({ ...settings }),
set: async (updates) => Object.assign(settings, updates),
},
onChanged: {
addListener: (listener) => {
storageChangedListener = listener;
},
},
},
tabs: { query: async () => [] },
};
const context = {
chrome,
console,
fetch: async () => {
throw new Error("offline test");
},
importScripts: () => {},
setTimeout: () => 1,
clearTimeout: () => {},
URL,
DEER_CLIPPER_DEFAULTS: settings,
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
deerMakeWorkerId: () => "test-worker",
deerAuthHeaders: () => ({}),
deerExtractAuthTokenFromPage: () => ({ token: "" }),
};
context.globalThis = context;
vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, {
filename: BACKGROUND_PATH,
});
return {
emitStorageChange(changes) {
storageChangedListener(changes, "sync");
},
getAlarmCreateCount: () => alarmCreateCount,
resetAlarmCreateCount: () => {
alarmCreateCount = 0;
},
getApiCandidates: (webBase, currentApiBase) =>
vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context),
};
}
const settle = () => new Promise((resolve) => setImmediate(resolve));
test("self-written sync metadata does not restart browser task polling", async () => {
const harness = createHarness();
await settle();
harness.resetAlarmCreateCount();
harness.emitStorageChange({
lastTokenSyncAt: { oldValue: "before", newValue: "after" },
});
await settle();
assert.equal(harness.getAlarmCreateCount(), 0);
});
test("polling configuration changes restart browser task polling", async () => {
const harness = createHarness();
await settle();
harness.resetAlarmCreateCount();
harness.emitStorageChange({
taskPollingEnabled: { oldValue: false, newValue: true },
});
await settle();
assert.equal(harness.getAlarmCreateCount(), 1);
});
test("service discovery prefers the API paired with the open zncm tab", async () => {
const harness = createHarness();
assert.deepEqual(
Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")),
[
"http://localhost:5174",
"http://localhost:8001",
"http://47.94.209.59:2026",
"http://localhost:8001",
].filter((value, index, values) => values.indexOf(value) === index),
);
});
+5 -45
View File
@@ -7,8 +7,6 @@
const HOST_ID = "zncm-page-assistant-host";
const state = {
enabled: true,
taskPollingEnabled: true,
webBaseUrl: "",
open: false,
busy: false,
threadId: "",
@@ -711,58 +709,20 @@
async function loadSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
state.enabled = Boolean(settings.pageAssistantEnabled);
state.taskPollingEnabled = Boolean(settings.taskPollingEnabled);
state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
ensureWidget();
}
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync") {
if (areaName !== "sync" || !changes.pageAssistantEnabled) {
return;
}
if (changes.pageAssistantEnabled) {
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
ensureWidget();
if (state.enabled) {
setStatus("页面问答框已开启。");
}
}
if (changes.taskPollingEnabled) {
state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue);
}
if (changes.webBaseUrl) {
state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl);
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
ensureWidget();
if (state.enabled) {
setStatus("页面问答框已开启。");
}
});
function isZncmPage() {
const appPath = `${location.pathname}${location.hash}`.toLowerCase();
if (
appPath.includes("/page/workspace") ||
appPath.includes("/chats/") ||
appPath.includes("#/login")
) {
return true;
}
try {
const configured = new URL(state.webBaseUrl);
return configured.origin === location.origin;
} catch {
return false;
}
}
// MV3 service workers may be suspended between their short polling timers,
// while chrome.alarms cannot reliably run more often than every 30 seconds.
// A visible zncm tab provides a cheap wake-up signal so browser_act requests
// are normally claimed within the backend/model timeout window.
setInterval(() => {
if (!state.taskPollingEnabled || !isZncmPage()) {
return;
}
chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {});
}, 3000);
setInterval(() => {
if (!state.enabled || !ui.pageTitle) {
return;
@@ -1,703 +0,0 @@
# AgentScope 2.0 多智能体报告协作工作台——前端实施计划
> 文档状态:待评审、尚未开始编码
> 编写日期:2026-09-02
> 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web`
> 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio`
> 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642`
> 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`
> 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。
## 1. 前端目标
新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。
前端必须满足:
1. 页面采用“左侧对话、右侧流程画布”的固定两栏布局,画布、工具栏、弹窗和抽屉参考 Coze Studio 页面风格。
2. 消息列表必须复用 DeerFlow 现有 `MessageList`,不复制 Coze 的会话消息实现。
3. 所有执行过程、AgentScope 团队消息、智能体回复、工具调用及其原始返回、错误、协助卡、返工和最终报告入口都通过现有消息协议按时间顺序显示在左侧对话区,不设置独立执行结果面板,也不另做“阶段摘要”替代实时消息。
4. 候选方案、流程节点、对话进度和报告使用同一份后端事实,不各自推断状态。
5. 用户可在规划中、运行中、等待输入和报告完成后继续对话。
6. 用户协助卡的出现和提交都不能在前端被解释为“节点已经完成”。
7. 页面刷新、SSE 断线重连、历史会话切换后能恢复到一致状态。
暂定:
- 页面名称:`报告协作`。
- 路由:`/page/workspace/report-collaboration`。
- 会话路由:`/page/workspace/report-collaboration/:sessionId`。
- 默认候选方案数量:3。
- 点击画布节点时打开覆盖式节点说明抽屉;抽屉只展示节点职责和合同,不展示节点执行结果。
---
## 2. 范围与非目标
### 2.1 本期范围
- 新建、查看、重命名、删除协作报告会话。
- 使用对话澄清报告需求。
- 显示 2~3 个候选方案卡片。
- 卡片与右侧流程图预览联动。
- 节点说明抽屉:职责、所依据的信息类型、预期产出、工具、资料要求和验收条件。
- 实时展示节点排队、运行、校验、等待用户、完成、返工、失败和取消。
- 在左侧对话时间线中直接展示每个智能体的流式文本、团队对话、工具调用和工具返回,并展示审稿意见、报告文件和报告版本。
- 并行阶段用横向的智能体选择器切换当前查看对象;选择器只控制同一份消息流的聚焦范围,不创建另一套执行结果视图。
- 运行期间发送补充要求、重新检索、重新分析、节点重跑等指令。
- 完成后进行报告问答、章节重写、全文润色和版本恢复。
- 报告模板选择、Markdown 预览、Markdown/Word 下载。
- 响应式布局、键盘操作、加载反馈和错误恢复。
### 2.2 明确不做
- 不引入 `@coze-workflow/playground` 或 `@coze-arch/coze-design`。
- 不加载 Coze workflow definition、run 或 node run。
- 不允许前端拖拽连线后直接改变执行逻辑。
- 不新增第二套消息气泡、Markdown、引用和文件卡渲染器。
- 不展示模型原始 chain-of-thought、系统/内部 prompt、密钥和被标记为内部控制信息的字段。
- 不把用户有权查看的 AgentScope TeamSay、智能体文本、工具调用或工具返回压缩成“正在检索”等摘要卡;这些原始业务消息必须进入复用的 `MessageList`。敏感字段只能按统一脱敏策略替换,不能以摘要替代。
- 不增加第三栏、常驻详情栏、节点执行结果栏或独立执行时间线。
- 节点抽屉不展示节点生成正文、工具返回、证据列表、错误堆栈或 attempt 历史;这些过程统一在左侧 `MessageList` 的原生消息/工具步骤中查看。
- 不改造现有圆桌、多智能体会商、深度研究和 AI 写作页面。
- 不把浏览器状态作为业务事实源。
---
## 3. 参考页面行为映射
| 目标行为 | Coze 参考文件 | DeerFlow 目标组件 | 说明 |
|---|---|---|---|
| 两栏骨架 | `workflow-studio-layout.tsx` 的面板比例与分隔条 | `ReportCollaborationLayout.tsx` | 左对话、右画布;只保留一条可调整分隔线 |
| 顶栏 | `workflow-studio-header.tsx` | `ReportCollaborationHeader.tsx` | 替换为会话、模板、报告、停止等操作 |
| 候选卡片 | `workflow-proposal-cards.tsx` | `ReportPlanCards.tsx` | 点击预览,明确确认后才执行 |
| 右侧画布 | `workflow-canvas.tsx` | `ReportFlowCanvas.tsx` | 使用 `@xyflow/react` 重建,不引入 Coze Playground |
| 左侧消息区 | `run-conversation-panel.tsx` | `ReportConversationPanel.tsx` | 外层风格参考 Coze,内部复用 DeerFlow `MessageList` |
| 节点说明抽屉 | Coze 节点配置面板的视觉与抽屉行为 | `NodeInspectorDrawer.tsx` | 点击画布节点打开,只展示职责与输入输出合同 |
| 全程执行反馈 | 参考其进度和状态表达 | `AgentStageSelector` + DeerFlow `MessageList` | 选择智能体后直接显示其流式消息、TeamSay、工具调用与返回;不再合成为阶段摘要 |
| 状态栏 | `connection-status.tsx` | `ConnectionStatusBar.tsx` | 展示连接、重连和事件游标 |
| 对话历史 | `left-panel-tabs.tsx` | `ConversationSidebarTabs.tsx` | 对话与历史,不增加“节点资源”Tab |
| 异步反馈 | 规划进度、运行状态 | MessageList 步骤条 + 画布状态 | 操作超过 300ms 必须有反馈 |
主要参考源:
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-layout.tsx`
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-composition.tsx`
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-header.tsx`
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\run-conversation\workflow-proposal-cards.tsx`
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\styles.module.less`
若直接复制 Apache-2.0 源码片段或样式,必须保留版权头并维护来源说明;默认做行为映射和视觉重建。
---
## 4. 页面结构
```text
┌──────────────────────────────────────────────────────────────────────────┐
│ 返回 报告协作 / 会话标题 保存状态 模板 报告 停止/继续 更多 │
├────────────────────────────────┬─────────────────────────────────────────┤
│ 对话 / 历史 │ 协作流程图 │
│ │ │
│ DeerFlow MessageList │ @xyflow/react │
│ · 并行时:[协调者][市场][政策] │ · 节点状态与活动连线 │
│ · 用户与协调者/成员消息 │ · 缩放、适配视图、小地图 │
│ · 原生 ToolCall / ToolMessage │ · 点击节点打开覆盖式说明抽屉 │
│ · 协助卡、报告文件和版本 │ │
│ │ │
│ 统一输入框 │ │
├────────────────────────────────┴─────────────────────────────────────────┤
│ 已连接 · run_xxx · 事件 128 │
└──────────────────────────────────────────────────────────────────────────┘
```
节点抽屉覆盖在右侧画布之上,不占用第三栏:
```text
┌─────────────────────────────────────────┐
│ 节点名称 关闭 × │
│ 角色 / 当前状态 │
├─────────────────────────────────────────┤
│ 节点职责 │
│ 要根据什么:所需输入、上游类型、资料要求 │
│ 要产出什么:成果类型、格式、验收条件 │
│ 可用工具与边界 │
│ 上下游关系 │
├─────────────────────────────────────────┤
│ 在对话中对此节点提出意见 │
└─────────────────────────────────────────┘
```
抽屉明确不展示实际执行结果、生成正文、工具返回、来源明细、错误堆栈或 attempt 时间线;这些信息只在左侧原生消息流中展示。
布局默认值:
- 顶栏高度:56px。
- 左侧面板:默认 480px,最小 360px,最大 720px。
- 右侧画布:最小 480px,使用剩余空间。
- 节点说明抽屉:覆盖画布右侧,建议宽度 420px;关闭后完整归还画布空间。
- 状态栏:24~28px。
- 低于 960px:左侧对话和右侧画布改为顶部按钮切换的单面板模式;节点说明仍使用覆盖式 Drawer。
- 两栏分隔条:支持 pointer 拖动、方向键、Home、End。
### 4.1 原生消息流与并行智能体切换
“复用 `MessageList`”在本页面的含义是复用它现有的 assistant 文本、`tool_calls`、`tool` 消息、引用、文件和协助卡渲染语义;不把 AgentScope 的运行过程先解析成专用步骤条、进度摘要或阶段结果卡。
单个智能体:
- `agent.reply.started` 立即创建一条稳定的 assistant 消息;`message.delta` 持续更新同一 `messageId`,所以用户会先看到流式状态、再看到逐字回复,而不是等待节点结束。
- 工具调用创建/更新该 assistant 消息的 `tool_calls`;工具返回创建匹配 `tool_call_id` 的原生 `tool` 消息。现有 `MessageGroup` 负责把两者配对显示,不再另写工具过程渲染器。
- AgentScope TeamSay 作为带 `agentRunId` 的普通 assistant 消息进入同一流,按到达顺序显示。它不是节点完成的证据,节点状态仍由右侧画布和后端状态机决定。
并行智能体:
```text
多角度并行研究 3/4 进行中
[ 协调者 ] [ 市场规模 ✓ ] [ 品牌竞争 ● ] [ 海外市场 ● ] [ 政策风险 ! ]
└──────────────────── 当前选中智能体的原生 MessageList ────────────────────┘
```
- `AgentStageSelector` 只在并行阶段出现,横向放置在左侧对话区顶部;使用现有图标体系的头像/角色图标、名称、状态文字和非纯颜色状态标识。默认选中第一位已开始运行的成员;用户手动切换后保持选择,不因其他成员返回而抢走阅读焦点。
- 所有成员的事件都持续接收、按每个 `agentRunId` 持久化和更新,即使当前未选中;切回即可看到完整历史与正在更新的同一条消息,不丢 token、不重新请求、不把结果拼成摘要。
- 当前选择只将共享用户消息、协调者广播消息和目标成员消息组成一个 `BaseStream<AgentThreadState>` 交给同一个 `MessageList`。切换标签仅切换该视图的消息范围,不改变 run、节点或消息的后端事实。
- 某成员请求用户协助时,其标签显示“待回复”徽标,选择器显示待处理数;点击徽标切到该成员,原生协助工具消息仍由现有 `MessageList` 协助卡渲染。其他成员可继续运行。
- 节点抽屉打开某并行成员时,同步聚焦对应标签;抽屉仍只展示合同。用户从抽屉或输入框发送反馈时带 `targetNodeId`/`agentRunId`,后端在安全点生效。
这不是把完整日志另做成调试面板:对话区展示的是模型和工具的真实业务消息;只有 chain-of-thought、内部提示词及按权限必须脱敏的字段不进入该消息流。
### 4.2 页面主要状态
| 状态 | 左侧对话区 | 右侧流程画布 | 主要操作 |
|---|---|---|---|
| `empty` | 欢迎语和输入框 | 空态 | 输入需求、选模板 |
| `clarifying` | 澄清问题和协助卡 | 需求理解节点 | 回答或补充 |
| `planning` | 用户消息和规划进度 | 骨架或需求图 | 继续补充,等待安全点生效 |
| `proposal_ready` | 2~3 张方案卡 | 点击卡片预览对应图 | 切换、修改、采用 |
| `running` | 协调者与成员原生消息、工具过程和用户反馈 | 实时节点状态 | 干预、停止、在对话中要求重跑 |
| `awaiting_input` | 等待用户卡片、原因和影响 | 对应节点显示等待 | 回答、取消 |
| `reviewing` | 审稿意见和返工过程 | 评审/返工节点状态 | 接受或补充要求 |
| `completed` | 总结、报告文件卡、版本和后续对话 | 完整执行图 | 问答、改写、导出 |
| `failed` | 错误说明和恢复建议 | 失败节点 | 通过对话重试或取消 |
| `cancelled` | 已保留成果说明 | 未完成节点取消 | 从已有成果继续或新建方案 |
---
## 5. 前端目录与组件边界
建议新增:
```text
frontend-web/src/report-collaboration/
api/
client.ts
sessions.ts
plans.ts
runs.ts
reports.ts
types.ts
components/
ReportCollaborationLayout.tsx
ReportCollaborationHeader.tsx
ConversationSidebarTabs.tsx
ReportConversationPanel.tsx
AgentStageSelector.tsx
ReportComposer.tsx
ReportPlanCards.tsx
ReportFlowCanvas.tsx
ReportFlowNode.tsx
NodeInspectorDrawer.tsx
ReportPreviewDialog.tsx
InterventionCard.tsx
SessionHistoryPanel.tsx
ConnectionStatusBar.tsx
hooks/
useReportCollaborationSession.ts
useReportCollaborationStream.ts
useReportCollaborationMessages.ts
useReportPlanSelection.ts
useReportPanelLayout.ts
state/
event-normalizer.ts
event-reducer.ts
selectors.ts
message-adapter.ts
pages/
ReportCollaborationPage.tsx
styles/
report-collaboration.css
tests/
```
修改范围:
- `frontend-web/src/pages/WorkspaceRoutes.tsx`:增加页面和会话路由。
- `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx`:增加导航入口。
- `frontend-web/src/components/workspace/messages/message-list.tsx`:仅允许增加一个向后兼容的通用业务卡片插槽。
- `frontend-web/src/core/messages/utils.ts`:如确需识别通用业务卡类型,只做集中式扩展。
不得把新功能放进:
- `frontend-web/src/roundtable-planning/`
- `frontend-web/src/open-canvas/`
- Coze 项目的 `workflow-standalone/`
---
## 6. 数据来源与前端状态
### 6.1 三层状态
前端状态分为:
1. 服务器快照:session、原生 messages、plans、run、nodes、artifacts、report versions;每条消息带稳定的 `agentRunId`、`nodeRunId`、`phaseId` 和可见性标记,其中 artifacts 只用于生成左侧消息卡和报告文件,不形成独立结果面板。
2. durable SSE reducer:从 `seq` 连续应用事件,更新服务器快照投影。
3. 纯展示状态:预览中的方案、选中的节点、聚焦的并行 `agentRunId`、两栏宽度、节点抽屉开关、窄屏当前视图和画布位置。
纯展示状态不能改变执行结果。点击卡片只改变 `previewPlanId`;只有点击“采用方案”才改变服务器 `selectedPlanId`。
### 6.2 前端事件包
```ts
interface CollaborationEvent<T = unknown> {
eventId: string;
sessionId: string;
runId?: string;
seq: number;
type: string;
timestamp: string;
data: T;
}
```
必须处理:
- 快照先到、事件后到。
- 事件重复。
- 事件短暂乱序。
- SSE 断线后从 `after_seq` 重放。
- 页面切换时旧连接晚到事件。
- token delta 多次更新同一消息。
- TeamSay、assistant 文本、tool-call 参数 delta、tool 返回 delta 分别更新各自稳定消息,不得等节点完成后批量生成摘要。
- 并行成员的消息可交错到达;Reducer 必须按 `agentRunId + messageId` 更新,而不是按当前被选中的标签丢弃未聚焦成员的事件。
- 节点重新打开后出现新 attempt。
Reducer 以 `sessionId + runId + seq` 去重;消息行以 `messageId`、工具结果以 `toolCallId`、标签以 `agentRunId` 使用后端稳定 ID,禁止使用数组下标。
---
## 7. 分步实施计划
### FE-0:冻结参考视觉与行为基线
任务:
1. 在 1440×900、1280×800、1024×768 打开参考页面。
2. 保存初始态、方案态、运行态和节点说明抽屉四组截图。
3. 记录顶栏、两栏比例、间距、圆角、边框、阴影、画布工具栏和状态色。
4. 形成“参考组件—目标组件—允许差异”表。
5. 明确消息列表保持 DeerFlow 原样,不纳入像素对齐。
交付物:
- 视觉基线截图。
- 视觉 token 表。
- 页面行为清单。
验收:参考的每个用户可见状态都有截图或明确说明不存在。
### FE-1:路由、菜单和静态页面骨架
任务:
1. 增加两个路由和菜单项。
2. 创建 `ReportCollaborationPage`。
3. 创建顶栏、左侧对话栏、右侧画布和底部连接栏。
4. 实现可调整宽度的两栏布局,以及“专注对话/专注画布”切换。
5. 实现窄屏单面板切换与覆盖式节点 Drawer。
验收:
- 不连接后端也能呈现完整空态。
- 375~1440px 不出现页面级横向滚动。
- 对话/画布切换不会卸载会话状态或清空输入内容。
- 键盘可以调整两栏分隔条。
### FE-2:页面设计系统与 Coze 风格重建
建立页面专用语义变量:
```text
--rc-bg-page
--rc-bg-panel
--rc-bg-canvas
--rc-border
--rc-text-primary
--rc-text-secondary
--rc-accent
--rc-running
--rc-waiting
--rc-success
--rc-danger
--rc-panel-shadow
--rc-card-radius
```
规则:
- 从 DeerFlow 主题 token 派生,不在组件中散落十六进制颜色。
- 使用 Lucide SVG 图标,不使用 emoji。
- hover/focus/展开动画控制在 150~300ms。
- 支持 `prefers-reduced-motion`。
- 交互状态不能只靠颜色表达。
- 正文对比度达到 4.5:1。
- 操作超过 300ms 显示 spinner、骨架或流式状态。
验收:除 MessageList 内部外,两栏布局、密度、卡片、画布工具栏、弹窗和节点抽屉达到参考基线。
### FE-3:API 类型和 TanStack Query 层
实现:
- session CRUD。
- messages 查询与发送。
- plan request、plans 查询、select。
- run 创建、查询、停止、重试。
- command 创建、确认、取消。
- artifacts、sources、reports、versions 查询。
每个写请求携带:
- `idempotencyKey`
- `expectedRevision`
冲突行为:
- `409`:刷新当前快照并提示用户重新确认。
- `422`:在对应操作附近显示具体错误。
- `429`:显示预算/并发限制,不自动重试。
- `5xx`:保留用户输入,允许使用同一幂等键重试。
验收:API 层不包含页面展示逻辑,类型与后端 OpenAPI 一致。
### FE-4:SSE、快照和事件 Reducer
实现 `useReportCollaborationStream`:
1. 先拉 session snapshot。
2. 使用 snapshot 的 `lastSeq` 建立 SSE。
3. 校验每个事件 session/run 身份。
4. 按 seq 应用事件并去重。
5. 出现序号缺口时暂停 live apply,补拉缺失事件。
6. 断线指数退避并支持 `Last-Event-ID`。
7. 终态后完成最后一次事件补拉再关闭连接。
Reducer 必须覆盖:
- 消息流增量。
- 计划候选和 revision。
- node/node attempt 状态。
- artifact 创建和校验。
- command 分类和执行。
- report delta 和正式版本。
- 错误、取消、接管恢复。
- AgentScope 原始可见消息和工具事件:`message.created/delta/completed`、`tool_call.created/delta/completed`、`tool_result.created/delta/completed`、`team_message.created/delta/completed`。
验收:重复事件、乱序事件和重连都不产生重复消息、重复节点或状态倒退。
### FE-5:复用 DeerFlow `MessageList`
复用:
- `frontend-web/src/components/workspace/messages/message-list.tsx`
- `frontend-web/src/components/workspace/messages/message-list-item.tsx`
- `frontend-web/src/components/workspace/messages/message-group.tsx`
- `frontend-web/src/components/workspace/messages/context.ts`
实现无损协议适配 `message-adapter.ts`:
1. 将已持久化的 `CollaborationMessage` 一对一映射为 LangGraph SDK `Message[]`,不将文本或工具结果二次概括。
2. 构造最小 `BaseStream<AgentThreadState>` 外观:`messages`、`isLoading`、`isThreadLoading`、`values`。
3. 协调者、成员 AgentScope TeamSay 和成员回复都映射为普通 assistant 消息;保留 `messageId`、文本 chunk、角色/成员名称、`agentRunId`、`nodeRunId`、`phaseId`、来源事件 ID 和完成态。
4. AgentScope 工具调用映射为该 assistant 消息的标准 `tool_calls`;调用结果映射为标准 `tool` 消息,并严格复用原有 `tool_call_id` 配对。工具参数、返回正文及增量保持原文,只有后端明确标注的脱敏字段被替换。
5. 将来源、文件、报告 Markdown delta 和正式版本继续映射为既有引用、文件卡和普通 assistant 消息;规划卡、用户确认卡和报告替换卡仍通过一个通用业务卡片插槽渲染。
6. 错误、恢复建议、命令影响范围和报告版本变化也必须进入同一时间线;错误堆栈以可读错误消息替代,但不能吞掉已发生的工具/成员消息。
7. `selectMessagesForAgentRun(allMessages, focusedAgentRunId)` 只在客户端从已同步消息中选出共享消息与目标成员消息;并行选择器不得重新拉取、重新排序或改写消息。
约束:
- 不使用 LangGraph `useThreadStream` 发送消息。
- 新页面输入框调用报告协作 API。
- 不增加多个页面专用 MessageList props。
- 普通聊天、AI 写作、深度研究等未传插槽时必须零变化。
- 不另建执行结果列表、节点结果面板或成果时间线。
- 不用“运行中”“已检索 N 条”等专用摘要替代真实消息。`MessageList` 本身的流式状态和原生工具步骤是唯一过程视图。
验收:
- 历史和流式消息使用同一行稳定更新。
- 单成员首次开始回复、每次工具启动和工具返回均在 300ms 内出现可见的原生消息行或原生工具步骤;不允许持续数秒只有静态加载圈。
- 并行标签切换后显示目标成员的完整已持久化消息和实时 delta;未选中成员持续接收,标签上的运行/待回复状态及时更新。
- 用户上翻阅读时不强制滚底。
- 用户发送新消息后滚到最新位置。
- 卡片显示成功不改变后端节点状态。
### FE-6:需求澄清和候选方案卡片
`ReportPlanCards` 展示:
- 推荐标签、策略、标题、摘要和推荐理由。
- 分析角度和参与角色。
- 关键步骤与质量门。
- 预计时间、成本等级和资料范围。
- 方案验证错误。
交互:
- 点击卡片或“查看流程”只切换预览。
- “采用此方案”调用 select API。
- “开始协作”再次校验并创建 run。
- 方案调整后显示新 revision;旧 revision 只读。
- 过期 revision 返回 409 后不静默覆盖。
验收:连续快速切换三张卡时,画布最终显示最后一次选择,不闪回旧图。
### FE-7:协作流程画布
技术:`@xyflow/react`。可以借鉴但不直接耦合:
- `frontend-web/src/components/ai-elements/canvas.tsx`
- `frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx`
节点显示:
- 节点名、角色名和分析角度。
- 状态文字、图标和颜色。
- 等待用户、返工、失败和 superseded 标记。
画布能力:
- 自动布局。
- 拖动节点只保存浏览器展示位置。
- 缩放、适配视图、方向切换、小地图。
- 活动边低强度流动动画。
- 点击节点打开覆盖在画布上的节点说明抽屉。
- 节点重跑、补充或纠偏一律通过左侧对话完成;抽屉只提供“在对话中对此节点提出意见”入口。
- 方案切换后清理不属于新方案的本地坐标。
验收:
- 30 个节点内流畅操作。
- 单个 node event 不导致整个 React Flow 重挂。
- `awaiting_input`、`reopened`、`superseded` 不与 `completed` 混淆。
### FE-8:节点说明抽屉与对话定位
点击画布节点打开 `NodeInspectorDrawer`。抽屉只展示计划合同:
1. 节点名称、角色、分析角度和当前状态标签。
2. 节点职责:负责解决什么问题。
3. 要根据什么:所需输入类型、上游节点类型、资料范围和时效要求。
4. 要产出什么:成果类型、结构要求和验收条件。
5. 可用工具、禁止事项、预算上限。
6. 上游与下游关系。
抽屉不展示:
- 节点实际生成内容。
- 工具调用返回值。
- EvidenceBundle 或其他成果物正文。
- attempt 执行时间线。
- 错误堆栈和调试信息。
- 报告正文。
交互:
- “在对话中对此节点提出意见”关闭抽屉、聚焦左侧 Composer,并绑定 `targetNodeId` 上下文。
- 若该节点对应正在并行运行的成员,同时绑定该成员的 `agentRunId` 并切换左侧选择器,使用户发送前能先看到该成员的真实对话与工具过程。
- 用户仍需在对话区输入“重新检索”“重新分析”等要求;抽屉不直接发起业务变更。
- 状态标签来自后端节点状态,但不加载节点结果接口。
- 关闭抽屉不清空 selected node;再次点击同一节点可恢复说明。
报告预览不使用常驻第三栏。最终报告、章节候选和版本先作为左侧 MessageList 中的普通消息/文件卡出现;点击文件卡时复用 `ArtifactFileDetail` 或现有 Markdown 能力打开大尺寸 Dialog,关闭后回到两栏工作台。
验收:
- 抽屉宽度不改变两栏布局,不挤压左侧对话区。
- 抽屉只读取节点合同和图结构,不请求节点执行结果。
- 所有原生成员消息、工具过程、错误和报告入口都能在左侧对话时间线找到。
### FE-9:全过程对话干预
`ReportComposer` 根据状态显示不同提示:
- 空闲:输入报告需求。
- 规划中:补充要求将在规划安全点应用。
- 运行中:协调者正在判断影响范围。
- 等待用户:优先回答等待问题。
- 完成后:可问答、重写、润色或重新研究。
发送流程:
1. 生成 idempotency key。
2. 插入 pending 用户消息;带 `targetNodeId`/`agentRunId` 时在相关成员消息流和共享上下文中引用同一条用户消息,不重复持久化。
3. 显示“正在识别修改范围”。
4. `command.classified` 后展示意图和影响节点。
5. 高成本或低置信度命令显示确认卡。
6. `command.accepted` 后显示排队或等待安全点。
7. 完成后关联新 node attempt 或 report version。
8. 失败时保留原消息和重试入口。
典型映射:
| 用户输入 | 预期操作 |
|---|---|
| “增加政策风险角度” | 新增角度节点和受影响下游 |
| “资料太旧,重新找” | 重新检索指定证据包 |
| “竞争格局分析不到位” | 复用证据,重新分析 |
| “重写第二章” | 只生成章节替换候选 |
| “语气更正式” | 新报告版本,不重新研究 |
| “为什么得出这个结论” | 只读问答,不修改报告 |
验收:
- 双击发送不创建重复命令。
- 选中“品牌竞争分析师”后发送补充要求,只影响该成员及依赖分析,其他并行成员仍保持消息流和运行状态。
- 运行中反馈不会让 run 或 node 误变 completed。
- “重写第二章”不会触发全量检索。
- “资料太旧”不会只做文字润色。
### FE-10:会话历史、报告版本与导出
- History Tab 显示标题、状态、更新时间、当前版本。
- 新建会话不删除旧会话。
- 会话切换时先关闭旧 SSE,再加载新快照。
- 报告修改先生成候选,确认后创建新版本。
- 版本恢复创建新的头版本,不删除历史。
- 下载复用现有 Markdown/Word 能力。
- 文件名来自报告标题,不暴露宿主机或沙箱路径。
验收:历史会话可完整恢复左侧消息流程、右侧节点状态和报告版本;不额外恢复节点结果面板。
### FE-11:测试、视觉回归和性能
单元测试:
- event normalizer/reducer。
- message adapter。
- plan revision 和选择状态。
- node status 和 available actions。
- command pending/idempotency。
组件测试:
- 空态、澄清、规划、候选、运行、等待、完成、失败、取消。
- 卡片选择、节点点击、只读说明 Drawer、确认弹窗和报告预览 Dialog。
- MessageList 通用业务卡插槽零回归。
- 单成员流式文本、TeamSay、工具调用与工具返回的原样重放;并行成员交错到达、切换标签、切回后不中断 delta。
- 非当前标签成员发起协助时显示待回复徽标,点击后使用原生协助卡回复到正确节点。
E2E:
1. 新建会话并输入新能源汽车报告要求。
2. 选择候选并开始。
3. 运行中增加风险角度。
4. 重新检索海外最新资料。
5. 完成后重写第二章。
6. 刷新、断网重连、切换会话。
7. 停止和重试失败节点。
性能门:
- 30 节点画布不卡顿。
- 长消息列表使用现有优化策略。
- SSE 高频 delta 合帧后再 setState。
- 画布和消息区使用独立 selector,避免互相触发整树重渲染。
- 节点抽屉只订阅节点合同和状态,不能因阶段产出更新而反复重渲染。
- 只渲染当前聚焦成员和共享消息的可视窗口;非当前成员的消息仍写入 store,但不因 token delta 重渲染当前 `MessageList`。
---
## 8. 后端接口依赖
前端开发前,后端必须冻结以下接口:
```text
POST /api/report-collaboration/sessions
GET /api/report-collaboration/sessions
GET /api/report-collaboration/sessions/{session_id}
PATCH /api/report-collaboration/sessions/{session_id}
DELETE /api/report-collaboration/sessions/{session_id}
POST /api/report-collaboration/sessions/{session_id}/messages
GET /api/report-collaboration/sessions/{session_id}/messages
POST /api/report-collaboration/sessions/{session_id}/plan-requests
GET /api/report-collaboration/sessions/{session_id}/plans
POST /api/report-collaboration/sessions/{session_id}/plans/{plan_id}/select
POST /api/report-collaboration/sessions/{session_id}/runs
GET /api/report-collaboration/runs/{run_id}
GET /api/report-collaboration/runs/{run_id}/events
GET /api/report-collaboration/runs/{run_id}/stream
POST /api/report-collaboration/runs/{run_id}/commands
POST /api/report-collaboration/runs/{run_id}/cancel
GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract
GET /api/report-collaboration/sessions/{session_id}/reports
POST /api/report-collaboration/sessions/{session_id}/report-rewrites
POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/apply
```
前后端共享枚举:
- session status。
- run status。
- node/node attempt status。
- command intent/status。
- event type/version。
- artifact type/schema version。
- report version/change type。
---
## 9. 前端开发顺序
1. `RC-FE-001`:视觉截图与行为基线。
2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。
3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。
4. `RC-FE-004`:API 类型和 mock server。
5. `RC-FE-005`:SSE reducer 与快照恢复。
6. `RC-FE-006`:MessageList adapter 和通用卡片插槽。
7. `RC-FE-007`:需求澄清与候选方案卡。
8. `RC-FE-008`:流程画布和自定义节点。
9. `RC-FE-009`:只读节点说明抽屉与对话目标定位。
10. `RC-FE-010`:统一 Composer 和命令确认。
11. `RC-FE-011`:报告预览、版本和导出。
12. `RC-FE-012`:历史、重连和异常恢复。
13. `RC-FE-013`:组件/E2E/视觉/性能验收。
依赖规则:
- `RC-FE-004` 必须等待前后端契约冻结。
- 页面骨架和视觉可与后端 BE-0~BE-3 并行。
- 真实 SSE 联调必须等待后端 durable event store。
- 报告版本操作必须等待后端候选应用语义完成。
---
## 10. 前端完成定义
前端只有同时满足以下条件才算完成:
1. 页面结构与 Coze 参考页面逐状态验收通过。
2. 消息区复用 DeerFlow `MessageList`,其他聊天页面无回归。
3. 候选卡、左侧对话流程、右侧流程图和报告来自同一 session/run/event 状态。
4. 点击候选只预览,明确确认后才执行。
5. 用户可以在规划、运行、完成后三个阶段继续对话。
6. 协助卡显示或提交不会导致前端误标完成。
7. 节点点击只打开说明抽屉,可查看职责、所需输入、预期输出、工具和验收条件,不展示执行结果。
8. 节点重开、返工和 superseded 状态表达正确。
9. SSE 重连、刷新和历史切换无重复消息和状态倒退。
10. 报告修改以候选和版本形式完成,不直接覆盖正式报告。
11. 页面始终只有对话与画布两个常驻区域,所有执行过程和产出入口都能在对话时间线中找到。
12. 响应式、键盘、对比度、加载反馈和错误恢复通过验收。
File diff suppressed because it is too large. Load diff
@@ -1,304 +0,0 @@
# OpenVibeCoding 内网可视化应用生成平台:分步开发方案
> 文档状态:待实施
>
> 目标环境:现有 Ubuntu 22.04 Docker 服务器;对外可用端口限定为 `8020-8030`。
>
> 本文基于 2026-08-31 的只读巡检编写。未在目标服务器上安装、停止或修改任何服务。
## 1. 目标与范围
将 OpenVibeCoding 改造成一个使用公司内网模型、在公司服务器 Docker 沙箱中生成并运行可视化应用的工作台。用户通过对话生成项目,查看并修改文件,在浏览器中预览运行结果。
第一期只服务以下项目类型:
- 数据大屏、数据看板、海报和展示型页面;
- React + Vite + TypeScript 前端;
- ECharts 图表、流程图;
- 可选的轻量 Python FastAPI 接口;
- 项目文件浏览、编辑、运行日志和实时预览。
第一期明确不做:
- 腾讯云 CloudBase、SCF、TCR、CodeBuddy、云端一键部署;
- 任意语言、任意 npm/PyPI 包的无限制安装;
- 用户自定义 Dockerfile、Docker Socket 挂载、宿主机命令执行;
- 直接将沙箱暴露为独立公网端口;
- 对不可信代码提供“绝对安全”的隔离承诺。
## 2. 已确认的服务器基线
目标服务器当前具备:
| 项目 | 巡检结果 | 判断 |
|---|---:|---|
| 操作系统 | Ubuntu 22.04.5 LTS | 支持 Docker 方案 |
| CPU | 32 vCPU | 足够第一期 |
| 内存 | 125 GiB;巡检时可用约 106 GiB | 足够第一期 |
| 根磁盘 | 2 TB;巡检时可用约 1.6 TB | 足够 Harbor、镜像与工作区 |
| Docker | 29.1.3,Docker Compose 2.40.3 | 已满足 |
| 负载 | 约 0.5 | 当前有余量 |
| `8020-8030` | 巡检时无监听、无 Docker 端口映射 | 可规划使用 |
该服务器已承载 DeerFlow、Coze、LiteLLM、数据库和其他容器;`80`、`8000`、`8005`、`8006`、`3306`、`5432` 等端口已经被使用。因此,本项目不能占用现有端口、网络、数据库或容器名称。
该服务器有公网 IP,且已有多项服务绑定在 `0.0.0.0`。本方案将它视作“公网可达的共享主机”处理;云安全组和宿主机防火墙必须额外核验,不能仅凭端口监听结果假设安全。
## 3. 总体架构
```mermaid
flowchart LR
U[用户浏览器] -->|:8020| G[App Builder Gateway]
U -->|:8022 /preview/<sandboxId>/| P[Preview Router]
G --> O[OpenVibe Web / Agent Server]
O --> M[现有内网模型网关]
O --> DB[(独立 PostgreSQL)]
O --> R[(独立 Redis)]
O --> SM[Local Sandbox Manager]
SM --> D[Docker Engine]
D --> S1[visual-ui Sandbox]
D --> S2[visual-fullstack Sandbox]
P --> S1
P --> S2
D --> H[Harbor :8023]
S1 --> N[内网 pnpm/npm 源或预置离线依赖]
S2 --> N
```
设计原则:
1. 保留 OpenVibe 的对话、任务、文件树、工具卡片和 OpenCode ACP 代码代理体验。
2. 模型请求仅由 OpenVibe 服务端发往既有内网模型网关;API Key 不进入沙箱。
3. 每个项目运行于独立 Docker 容器和独立工作区;沙箱只运行项目代码,不承担平台控制逻辑。
4. 浏览器预览只经过统一预览路由,不为每个项目映射新的公网端口。
5. 先用固定模板和固定依赖清单保证稳定,后续再按白名单增加依赖。
## 4. 端口规划
端口虽然可用,但应尽量少暴露。除 8020、8022 和按需开放的 8023 外,其余服务只加入 Docker 内部网络。
| 端口 | 服务 | 绑定范围 | 用途 |
|---:|---|---|---|
| 8020 | `appbuilder-gateway` | 公网访问面或 VPN 白名单 | 控制台页面、`/api`、SSE/WebSocket |
| 8021 | 预留 | 仅 `127.0.0.1` | 平台调试;第一期不对外开放 |
| 8022 | `preview-router` | 公网访问面或 VPN 白名单 | 统一预览入口:`/preview/<sandboxId>/` |
| 8023 | Harbor | 仅沙箱节点、构建节点和管理员网段 | 私有镜像仓库 HTTPS Registry |
| 8024 | 预留 | 不对外开放 | 后续 npm 私服或运维服务 |
| 8025-8030 | 预留 | 不对外开放 | 扩展、迁移或故障切换 |
### 4.1 预览路由规则
暂时没有可用泛域名时,使用路径型预览:
```text
http(s)://<服务器地址>:8022/preview/<sandboxId>/
```
项目模板的 Vite `base` 和 HMR 路径必须由启动脚本注入为该前缀,确保图片、JS/CSS 资源及热更新不会错误请求到根路径。后续若获得 `*.preview.company.local` 内网泛域名,可切换为子域名预览,用户体验更好。
## 5. 镜像、模板与离线依赖策略
### 5.1 标准镜像
第一期维护两种版本化镜像,不允许用户自行构建 Dockerfile:
| 镜像 | 用途 | 预置内容 |
|---|---|---|
| `appbuilder/visual-ui:<version>` | 大屏、海报、纯前端 | Node.js 20、pnpm、React、Vite、TypeScript、ECharts、流程图库、统一 UI 库、Tailwind、图标与中文字体 |
| `appbuilder/visual-fullstack:<version>` | 前端加接口 | 上述前端依赖 + Python 3.12、FastAPI、Uvicorn、Pydantic |
流程图库第一期统一选择一种(建议 React Flow),避免同时生成多种流程图语法和样式。地图、背景图、字体等资源也应作为受版本控制的本地资产打包,不能依赖 Google Fonts 或公网 CDN。
### 5.2 项目模板
每次创建项目时从镜像中的模板初始化到 `/workspace`:
```text
templates/
visual-screen/ # React + ECharts + React Flow + Mock 数据
visual-screen-api/ # visual-screen + FastAPI + /api 反向代理
poster-page/ # 海报、营销展示、静态动效
```
`visual-screen-api` 约定:前端只请求相对路径 `/api/*`;预览网关将其转发到同一沙箱的 FastAPI `8000` 端口。没有后端时保留 Mock 数据模块,生成的页面仍可运行。
### 5.3 依赖预置方式
不能仅把安装命令写进 Dockerfile 后在用户创建项目时再联网执行。正确做法是:
1. 在受控构建环境中锁定 `package.json`、`pnpm-lock.yaml`、`requirements.lock`。
2. 构建镜像时执行 `pnpm fetch`,在镜像保存只读 pnpm store;Python 包保存到 `/opt/wheelhouse`。
3. 沙箱初始化使用 `pnpm install --offline --frozen-lockfile` 和 `pip install --no-index --find-links=/opt/wheelhouse`。
4. 第一期开启“固定依赖白名单”:AI 只能使用镜像已安装的依赖;新依赖先由管理员审核、加入锁文件和下一版基础镜像。
5. 第二期部署 Verdaccio/Nexus 等内部制品源,作为受控补充而非允许访问公网 npm/PyPI。
## 6. OpenVibe 改造边界
### 6.1 保留
- 前端交互:对话、任务状态、文件浏览与修改、预览、日志;
- OpenCode ACP 代码代理;
- 项目与会话领域模型;
- 代码代理的流式事件协议。
### 6.2 替换或关闭
| OpenVibe 当前能力 | 第一期开源/内网实现 |
|---|---|
| CodeBuddy | 只启用 OpenCode,配置内网 OpenAI 兼容模型网关 |
| CloudBase、SCF 沙箱 | 新增 `LocalDockerSandboxManager` |
| TCR | Harbor |
| CloudBase 环境、用户环境池 | Docker 容器、工作区卷、沙箱记录 |
| CloudBase 消息与事件持久化 | 独立 PostgreSQL;Redis 用于临时队列与状态 |
| CloudBase 存储、函数、MCP、资源管理后台 | 第一期开关关闭或移除入口 |
| CloudBase 项目模板和系统提示词 | 替换为大屏/海报/FastAPI 模板与受控依赖提示词 |
重点改造点是将 OpenVibe 的 `scf-sandbox-manager` 和 CloudBase 生命周期调用替换为本地 Docker 生命周期。为降低前端和 OpenCode 工具改动范围,新管理器应适配现有的文件、命令、日志、健康检查与工作区信息契约,而不是改写所有 Agent 工具。
## 7. 沙箱安全基线
每个沙箱容器必须满足:
- 非 root 用户运行;禁止 `privileged`,禁止 Docker Socket,禁止宿主机目录挂载;
- `--cap-drop=ALL`、`no-new-privileges`、`pids-limit`;
- 首期默认限制:`1 vCPU`、`3 GB 内存`、`10 GB 工作区`、60 分钟空闲回收;
- 使用独立 Docker network;默认无公网出口,只允许访问预置依赖、必要的内网 DNS 和平台回调;
- 数据库、Redis、Harbor 管理接口、宿主机管理端口不在沙箱网络可访问范围;
- 运行命令、容器生命周期、文件变更和预览访问写入审计日志;
- 平台服务由受限服务账号调用 Docker API;用户代码永远不直接获得 Docker 权限。
第一期为“内部可信用户的容器隔离”。若后续允许大量非可信代码或文件上传,应迁移为 Kubernetes 节点,并评估 gVisor/Kata 等更强运行时隔离。
## 8. 分步实施计划
### 阶段 0:部署前隔离与基线确认
**工作项**
1. 为本项目建立独立目录、Docker Compose Project、卷和网络,命名统一为 `appbuilder-*`。
2. 确认云安全组、iptables/nftables 规则:仅允许管理员 SSH;8020、8022、8023 仅向 VPN 或指定办公出口开放。
3. 不复用当前服务器上已有的 MySQL、PostgreSQL、Redis、Nginx 配置;建立独立实例或独立命名空间。
4. 创建内部 CA 证书或确定可信 HTTPS 证书方案;Docker 构建/运行节点信任 Harbor 证书。
5. 轮换目标服务器 root 密码,改用 SSH 密钥和受限运维账号;关闭 root 密码远程登录。
**验收**
- `8020-8030` 无端口冲突;
- 新容器、网络和数据卷均不影响既有 DeerFlow 等服务;
- 业务数据库和沙箱端口无法从非授权公网地址访问。
### 阶段 1:内网基础设施与镜像供应链
**工作项**
1. 在独立 Compose 项目中部署 Harbor,主入口为 `8023`,镜像数据使用独立持久化目录。
2. 构建并推送 `visual-ui`、`visual-fullstack` 两种基础镜像;记录镜像版本、SBOM 和依赖锁文件。
3. 完成三个模板及离线依赖初始化脚本。
4. 先不启动漏洞扫描;严格断网环境下后续通过离线包维护漏洞库。
5. 预留内部 npm/PyPI 源接入方式;第一期仅使用预置依赖。
**验收**
- Docker daemon 能从 Harbor 拉取指定版本镜像;
- 在禁止公网访问的测试网络中,三个模板均能启动并构建;
- 一个 ECharts 页面、一个流程图页面和一个 FastAPI `/api/health` 均可运行。
### 阶段 2:OpenVibe 去 CloudBase 化与内网模型接入
**工作项**
1. Fork 固定版本的 OpenVibeCoding,建立 `local-platform` 改造分支,记录上游 commit 与许可证。
2. 只保留 OpenCode Agent;将 provider 指向现有内网模型网关,模型凭证只保存在服务端环境变量或密钥服务。
3. 将基础实体、任务、消息、流式事件的持久化迁移为独立 PostgreSQL;不再调用 CloudBase 集合。
4. 关闭 CloudBase 环境、函数、存储、数据库、资源仪表盘和相关 MCP 入口。
5. 将系统提示词和项目创建默认项改为“可视化页面 + 受控模板/依赖”。
**验收**
- 不设置任何腾讯云、CloudBase、CodeBuddy、TCR 环境变量时,平台可启动;
- 用户发起“生成 ECharts 大屏”后,OpenCode 能调用内网模型并生成项目文件;
- 服务端日志和沙箱环境中均没有模型 API Key。
### 阶段 3:本地沙箱管理器与预览路由
**工作项**
1. 实现 `LocalDockerSandboxManager`:创建、启动、停止、超时回收、查询状态和删除容器。
2. 每个沙箱分配工作区卷、随机内部容器地址、模板类型和资源限制;在数据库记录 `sandbox_id`、项目、用户、镜像版本、过期时间和状态。
3. 实现或适配沙箱内 runner:文件读写、受限命令执行、日志流、健康检查、前端端口和 FastAPI 端口发现。
4. 实现 `preview-router`:由平台根据 `sandboxId` 维护路由,将 `/preview/<sandboxId>/` 代理到对应内部容器;支持 WebSocket/HMR。
5. 增加容器异常退出、端口未就绪、构建失败、超时与手动重启的可见状态。
**验收**
- 两个用户创建的项目存在于不同容器和不同工作区;
- 修改 ECharts 配置文件后,预览地址显示修改结果;
- FastAPI 模板的 `/api/health` 可通过前端相对路径访问;
- 沙箱不能访问 Docker Socket、宿主机数据库和非白名单网络;
- 空闲超时后容器被回收,项目文件仍可恢复。
### 阶段 4:产品能力收口
**工作项**
1. 在文件面板支持安全的文件查看、编辑、保存、目录树刷新与下载;限制文件大小和可编辑扩展名。
2. 在对话中增加模板选择和能力提示:大屏、流程图、海报、可选 FastAPI。
3. 为 Agent 增加依赖白名单、图表主题、中文字体、Mock 数据、接口契约等明确提示词。
4. 增加“运行/停止/重启/删除沙箱”和日志面板;仅项目所有者或管理员有相应权限。
5. 增加项目快照、模板版本记录和基础审计日志。
**验收**
- 从一句自然语言需求到可预览项目的完整链路可完成;
- 用户可查看并手动修改文件,刷新后改动保留;
- 页面覆盖 ECharts、流程图、响应式大屏和可选 FastAPI 接口;
- 非拥有者不能读取、修改或预览另一用户的项目。
### 阶段 5:上线前安全与运行验收
**工作项**
1. 压测 6 个并发沙箱,并记录 CPU、内存、磁盘、容器启动时间、首屏预览时间和模型等待时间。
2. 验证镜像保留策略、工作区配额、容器回收、数据库备份和 Harbor 备份恢复。
3. 执行越权、路径穿越、命令注入、网络探测、资源耗尽和模型密钥泄露测试。
4. 建立日常运维操作:新增基础依赖、构建镜像、发布模板、回滚镜像、清理工作区和审计查询。
**验收**
- 并发运行时既有 DeerFlow 等服务不受明显影响;
- 任一沙箱异常退出不会影响其他项目或平台控制服务;
- 备份可恢复项目元数据、工作区和 Harbor 镜像;
- 仅开放计划中的入口端口,内部组件无不必要公网监听。
## 9. 首期资源与并发建议
由于该主机已有多项业务,首期不应按理论最大值分配:
| 组件 | 首期限制 |
|---|---|
| Harbor | 8 vCPU、16 GB 内存、500 GB 数据盘配额 |
| OpenVibe 控制服务 | 4 vCPU、8-16 GB 内存 |
| 单个 UI 沙箱 | 1 vCPU、3 GB 内存、10 GB 工作区 |
| 单个 Fullstack 沙箱 | 1.5 vCPU、4 GB 内存、12 GB 工作区 |
| 沙箱并发 | 先限制为 6;稳定后再逐步上调 |
当并发超过 10 个,或要面向更广泛用户开放时,应优先增加独立沙箱节点,而不是继续在本共享主机上加容器。
## 10. 实施顺序与交付物
建议按以下提交/交付顺序推进,每一步都可独立验收和回退:
1. `docs: confirm appbuilder network, ports and security baseline`
2. `infra: add Harbor and versioned visual sandbox images`
3. `infra: add offline templates and dependency locks`
4. `feat: replace OpenVibe CloudBase persistence and model provider`
5. `feat: add local Docker sandbox manager`
6. `feat: add sandbox runner and path-based preview router`
7. `feat: add visual-screen and FastAPI project workflows`
8. `test: add sandbox isolation, preview and recovery coverage`
9. `ops: add backup, cleanup, monitoring and rollout runbook`
达到阶段 3 即可交付“内网模型驱动、生成并预览大屏/海报页面”的 PoC;达到阶段 5 后再作为受控的内部多人平台发布。
@@ -1,7 +0,0 @@
# 技能知识归纳与“知识梳理”助手知识库
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
<title id="title">CM 助手</title>
<defs>
<linearGradient id="logoTopLeft" x1="7" y1="7" x2="25" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#5DB5FF"/>
<stop offset="1" stop-color="#328AF2"/>
</linearGradient>
<linearGradient id="logoTopRight" x1="24" y1="3" x2="43" y2="19" gradientUnits="userSpaceOnUse">
<stop stop-color="#62B2FF"/>
<stop offset="1" stop-color="#4B98F4"/>
</linearGradient>
<linearGradient id="logoLeft" x1="5" y1="14" x2="24" y2="45" gradientUnits="userSpaceOnUse">
<stop stop-color="#2C7CF0"/>
<stop offset="1" stop-color="#1F5EDA"/>
</linearGradient>
<linearGradient id="logoBottom" x1="9" y1="31" x2="43" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#276DE3"/>
<stop offset="1" stop-color="#4E9DF5"/>
</linearGradient>
</defs>
<!-- The mark is drawn as four independent folded faces. Together they form
the open C-shaped cube used in the product reference. -->
<path d="M5 13.5 24 3v10L14 18.5 5 13.5Z" fill="url(#logoTopLeft)"/>
<path d="M24 3 43 13.5 34 18.5 24 13V3Z" fill="url(#logoTopRight)"/>
<path d="M5 13.5 14 18.5v11L24 35v10L5 34.5v-21Z" fill="url(#logoLeft)"/>
<path d="M14 29.5 24 35l10-5.5 9 5L24 45 5 34.5l9-5Z" fill="url(#logoBottom)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -56,11 +56,12 @@ import {
type ChangeEventHandler,
Children,
type ClipboardEventHandler,
type ComponentProps,
createContext,
type FormEvent,
type FormEventHandler,
type HTMLAttributes,
type ComponentProps,
createContext,
type FormEvent,
type FormEventHandler,
Fragment,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
type ReactNode,
@@ -123,11 +124,9 @@ export const usePromptInputController = () => {
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components and for
// Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController);
// Optional variants (do NOT throw). Useful for dual-mode components.
const useOptionalPromptInputController = () =>
useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
@@ -404,14 +403,14 @@ export function PromptInputAttachments({
return (
<div
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
{...props}
>
{attachments.files.map((file) => (
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
{...props}
>
{attachments.files.map((file) => (
<Fragment key={file.id}>
<div className="max-w-60">{children(file)}</div>
</Fragment>
))}
</div>
);
}
+3 -1
View File
@@ -27,7 +27,9 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
// Keep the workbench navigation compact across every theme. The visual theme
// only changes tokens; the information density should remain consistent.
const SIDEBAR_WIDTH = "14rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -0,0 +1,55 @@
"use client";
import agentAvatarDark from "@/assets/img/agent-avatar.png";
import agentAvatarLight from "@/assets/img/agent-avatar-red.png";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
type AgentBrandIconProps = {
className?: string;
size?: "nav" | "sm" | "md" | "lg";
};
const SIZE_STYLES = {
nav: {
frame: "size-[22px] rounded-md",
},
sm: {
frame: "size-10 rounded-full",
},
md: {
frame: "size-12 rounded-full",
},
lg: {
frame: "size-16 rounded-full",
},
} as const;
export function AgentBrandIcon({
className,
size = "md",
}: AgentBrandIconProps) {
const styles = SIZE_STYLES[size];
const { theme } = useTheme();
const avatarSrc =
theme === "light" ? agentAvatarLight : agentAvatarDark;
return (
<span
aria-hidden="true"
data-agent-brand-icon={size}
className={cn(
"relative inline-flex shrink-0 overflow-hidden rounded-full",
styles.frame,
className,
)}
>
<img
src={avatarSrc}
alt=""
className="size-full object-contain"
draggable={false}
/>
</span>
);
}
@@ -1,13 +1,13 @@
"use client";
import {
BotIcon,
import {
ClockIcon,
CopyIcon,
EyeOffIcon,
GlobeIcon,
LockIcon,
MessageSquareIcon,
MessageSquareIcon,
MoreHorizontalIcon,
PinIcon,
ShieldIcon,
SparklesIcon,
@@ -28,7 +28,7 @@ import {
} from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -38,14 +38,21 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -59,7 +66,8 @@ import { Textarea } from "@/components/ui/textarea";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -548,10 +556,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
{isOwned && (
<Badge
variant="default"
className="border border-emerald-200 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
>
我的
</Badge>
@@ -559,7 +567,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
>
来自他人
@@ -568,7 +576,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
title="该智能体已对所有用户可见"
>
<GlobeIcon className="size-3" />
@@ -663,8 +671,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
node: (
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
node: (
<Badge
key={`tg:${group}`}
variant="outline"
className="border-0 bg-primary/5 text-primary hover:bg-primary/10 px-2.5 py-1 text-xs font-normal"
>
{desensitize(group)}
</Badge>
),
@@ -674,8 +686,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
node: (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
variant="secondary"
className="bg-primary/5 text-primary hover:bg-primary/10 cursor-pointer border-0 px-2.5 py-1 text-xs font-normal"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
@@ -823,86 +835,185 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</>
);
const chatButton = (
<Button
size="sm"
className={cn(isList ? "shrink-0" : "w-full")}
onClick={handleChat}
const chatButton = (
<Button
variant={isList ? "default" : "outline"}
size="sm"
className={cn(
isList
? "bg-primary hover:bg-primary/90 shrink-0 rounded-xl px-4 font-medium text-primary-foreground"
: "border-primary/35 text-primary hover:border-primary/55 hover:bg-primary/5 h-11 w-full rounded-lg bg-white font-medium shadow-none dark:bg-slate-900 dark:text-primary",
)}
onClick={handleChat}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
开始对话
</Button>
);
);
const cardStatusBadge = agent.is_private ? (
<Badge className="border-0 bg-purple-50 px-2.5 py-1 text-xs font-normal text-purple-600 hover:bg-purple-50 dark:bg-purple-500/15 dark:text-purple-300">
<span className="mr-1.5 size-1.5 rounded-full bg-purple-500" />
私密
</Badge>
) : agent.published || isBuiltin ? (
<Badge className="border-0 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-normal dark:bg-primary/15 dark:text-primary">
<span className="bg-primary mr-1.5 size-1.5 rounded-full" />
已发布
</Badge>
) : (
<Badge className="border-0 bg-emerald-50 px-2.5 py-1 text-xs font-normal text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300">
<span className="mr-1.5 size-1.5 rounded-full bg-emerald-500" />
运行中
</Badge>
);
const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at);
const cardActionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-8 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-white"
title="更多操作"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{canFavorite && (
<DropdownMenuItem onSelect={() => void handleToggleFavorite()}>
<StarIcon className={cn("size-4", isFavorite && "fill-current text-amber-500")} />
{isFavorite ? "取消收藏" : "收藏到我的"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void handleTogglePin()}>
<PinIcon className={cn("size-4", isPinned && "fill-current text-emerald-600")} />
{isPinned ? "取消置顶" : "置顶"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onSelect={() => void handleToggleFeatured()}>
<SparklesIcon className="size-4" />
{agent.featured_order != null ? "取消推荐" : "设为推荐"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setTagPickerOpen(true)}>
<TagsIcon className="size-4" />
{t.adminTags.manageTags}
</DropdownMenuItem>
{isOwned && (
<DropdownMenuItem onSelect={() => void handleTogglePublished()}>
{agent.published ? <LockIcon className="size-4" /> : <GlobeIcon className="size-4" />}
{agent.published ? "取消发布" : "发布"}
</DropdownMenuItem>
)}
{(isOwned || isAdmin) && (
<DropdownMenuItem onSelect={() => void handleTogglePrivate()}>
<ShieldIcon className="size-4" />
{agent.is_private ? "移出私密广场" : "放入私密广场"}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDuplicate()}>
<CopyIcon className="size-4" />
复制智能体
</DropdownMenuItem>
{canEdit && (
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
<SquarePenIcon className="size-4" />
编辑智能体
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" />
{t.agents.delete}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
const cardLayout = (
<Card
className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"
data-agent-guide="agent-card"
>
<CardHeader className="min-w-0 pb-2">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{statusBadges}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
const cardLayout = (
<Card
className={cn(
"group flex min-h-[334px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-primary/30 hover:!bg-primary/5 hover:!ring-1 hover:!ring-primary/10 hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:border-slate-800 dark:bg-slate-900",
agent.featured_order != null && "border-primary/40 ring-1 ring-primary/15 dark:border-primary/45 dark:ring-primary/20",
)}
data-agent-guide="agent-card"
>
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
{agent.featured_order != null ? (
<>
<div className="mb-1 flex h-7 items-center justify-between">
<Badge className="border border-primary/20 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-medium dark:border-primary/40 dark:bg-primary/15 dark:text-primary">
<SparklesIcon className="mr-1 size-3.5 fill-current" />
推荐
</Badge>
{cardActionsMenu}
</div>
<AgentBrandIcon size="lg" />
</>
) : (
<div className="flex items-start justify-between">
<AgentBrandIcon size="lg" />
{cardActionsMenu}
</div>
)}
<CardTitle
className="mt-3 block w-full min-w-0 overflow-hidden text-[18px] leading-6 font-semibold tracking-[-0.015em] text-ellipsis whitespace-nowrap text-slate-950 dark:text-white"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.description && (
<CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="px-5 pt-0 pb-1.5">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{hasSkillsOrTools && (
<CardContent className="pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} />
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
{showActionRow && (
<div className="flex flex-wrap items-center justify-end gap-1">
{actionButtons}
</div>
)}
{chatButton}
</CardFooter>
<CardContent className="px-5 pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} />
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-4 px-5 pt-2 pb-4">
<div className="flex min-w-0 items-center gap-3">
{cardStatusBadge}
<span
className="min-w-0 truncate text-xs text-slate-400"
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
>
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
</span>
</div>
{chatButton}
</CardFooter>
</Card>
);
const listLayout = (
<div
className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]"
data-agent-guide="agent-card"
>
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div
className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-primary/25 hover:shadow-[0_12px_32px_rgba(15,23,42,0.09)] dark:border-slate-800 dark:bg-slate-900"
data-agent-guide="agent-card"
>
<AgentBrandIcon className="agent-avatar" size="md" />
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
<span
@@ -428,15 +428,11 @@ export function ArtifactFileDetail({
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
const streamingWriteContentPending =
useWriteFileStreaming && writeFileInFlight && !content;
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
@@ -1770,10 +1766,10 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit}
/>
)}
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
{artifactContentPending && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
正在加载文档内容…
</div>
)}
{artifactContentUnavailable && viewMode === "code" && (
@@ -1791,7 +1787,7 @@ export function ArtifactFileDetail({
</button>
</div>
)}
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
@@ -1,7 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useQuery } from "@tanstack/react-query";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
@@ -15,12 +15,9 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api";
import {
artifactPathname,
listThreadArtifacts,
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
@@ -95,13 +92,7 @@ const ChatBox: React.FC<{
knowledgeReferencesEnabled = false,
} = useThread();
const pathname = usePathname();
const searchParams = useSearchParams();
const requestedArtifact = useMemo(() => {
const value = searchParams.get("artifact")?.trim();
return value ? artifactPathname(value) : null;
}, [searchParams]);
const threadIdRef = useRef(threadId);
const openedRequestedArtifactRef = useRef<string | null>(null);
const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null);
const latestReferenceBatchIdRef = useRef<string | null>(null);
@@ -113,9 +104,6 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
@@ -142,7 +130,7 @@ const ChatBox: React.FC<{
const { skills } = useSkills();
const canScanArtifactDirectory =
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
artifactDirectoryModeEnabled &&
!disableArtifacts &&
!isMock &&
!!threadId &&
@@ -160,21 +148,17 @@ const ChatBox: React.FC<{
);
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
const availableArtifacts = useMemo(
() => {
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
return checkpointArtifacts;
}
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
(file) => file.path,
);
if (requestedArtifact) discoveredArtifacts.push(requestedArtifact);
return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts);
},
() =>
artifactDirectoryModeEnabled
? mergeArtifactPaths(
checkpointArtifacts,
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
)
: checkpointArtifacts,
[
artifactDirectoryModeEnabled,
artifactListingQuery.data?.files,
checkpointArtifacts,
requestedArtifact,
],
);
@@ -200,7 +184,6 @@ const ChatBox: React.FC<{
deselect,
selectedArtifact,
selectedArtifactKey,
openStreamedArtifact,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
@@ -218,29 +201,6 @@ const ChatBox: React.FC<{
thread.isLoading,
);
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
useEffect(() => {
if (
disableArtifacts ||
!requestedArtifact ||
!threadId ||
threadId === "new"
) {
return;
}
const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
if (openedRequestedArtifactRef.current === requestKey) return;
openedRequestedArtifactRef.current = requestKey;
selectArtifact(requestedArtifact);
setArtifactsOpen(true);
setSideTab("document");
}, [
disableArtifacts,
requestedArtifact,
selectArtifact,
setArtifactsOpen,
threadId,
]);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
setBackendReferenceBatches([]);
@@ -438,12 +398,9 @@ const ChatBox: React.FC<{
((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) {
const newest = nextArtifacts[nextArtifacts.length - 1]!;
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
selectArtifact(newest);
setArtifactsOpen(true);
setSideTab("document");
}
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
setSideTab("document");
}
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
@@ -475,30 +432,8 @@ const ChatBox: React.FC<{
setArtifacts,
availableArtifacts,
disableArtifacts,
thread.messages,
writingModeEnabled,
]);
// Live sandbox: open as soon as any in-flight write_file has a path or
// the first content bytes — not only when that tool is the last CoT step.
// Agentfx (and other agents) often emit write_file + present_files/bash in
// the same turn; the collapsed earlier write would otherwise never auto-open.
const streamingWritePreview = useMemo(
() =>
disableArtifacts || !thread.isLoading
? null
: latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled),
[disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled],
);
useEffect(() => {
if (!streamingWritePreview) return;
openStreamedArtifact(
streamingWritePreview.artifactPath,
streamingWritePreview.previewKey,
);
setSideTab("document");
}, [openStreamedArtifact, streamingWritePreview]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current;
@@ -1,7 +1,7 @@
import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef } from "react";
import { useOptionalPromptInputController } from "@/components/ai-elements/prompt-input";
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { useI18n } from "@/core/i18n/hooks";
/**
@@ -11,11 +11,7 @@ export function useSpecificChatMode() {
const { t } = useI18n();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
// A regular history item can navigate here while Vite Fast Refresh is
// reconciling the previous page tree. In that short window the page may see
// a stale provider context. This hook only pre-fills the skill-mode draft,
// so it must not make an ordinary chat page fail to render.
const promptInputController = useOptionalPromptInputController();
const promptInputController = usePromptInputController();
const inputInitialValue = useMemo(() => {
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
return undefined;
@@ -23,21 +19,16 @@ export function useSpecificChatMode() {
return t.inputBox.createSkillPrompt;
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
const lastInitialValueRef = useRef<string | undefined>(undefined);
const setInputRef = useRef<((value: string) => void) | undefined>(
promptInputController?.textInput.setInput,
);
setInputRef.current = promptInputController?.textInput.setInput;
const setInputRef = useRef(promptInputController.textInput.setInput);
setInputRef.current = promptInputController.textInput.setInput;
useEffect(() => {
if (
inputInitialValue &&
inputInitialValue !== lastInitialValueRef.current
) {
lastInitialValueRef.current = inputInitialValue;
setTimeout(() => {
const setInput = setInputRef.current;
if (!setInput) return;
setInput(inputInitialValue);
setTimeout(() => {
setInputRef.current(inputInitialValue);
const textarea = document.querySelector("textarea");
if (textarea) {
textarea.focus();
@@ -0,0 +1,222 @@
"use client";
import {
BookOpenCheck,
Brain,
CalendarClock,
MessageSquare,
MessageSquarePlus,
PanelLeftClose,
PenLine,
Settings,
Sparkles,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { MouseEvent } from "react";
import { useNavigate } from "react-router-dom";
import cmAssistantLogoDark from "@/assets/img/cm-assistant-logo.png";
import cmAssistantLogoLight from "@/assets/img/cm-assistant-logo-red.png";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
import { AgentBrandIcon } from "./agents/agent-brand-icon";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
type NavItem = {
label: string;
href: string;
icon: typeof MessageSquare;
match: string;
};
const NAV_ITEMS: NavItem[] = [
{ label: "新建对话", href: NEW_CHAT_PATH, icon: MessageSquarePlus, match: NEW_CHAT_PATH },
{ label: "对话", href: "/page/workspace/chats", icon: MessageSquare, match: "/page/workspace/chats" },
{ label: "智能体", href: "/page/workspace/agents", icon: Sparkles, match: "/page/workspace/agents" },
{ label: "技能", href: "/page/workspace/skills", icon: Sparkles, match: "/page/workspace/skills" },
{ label: "记忆", href: "/page/workspace/memory", icon: Brain, match: "/page/workspace/memory" },
{ label: "AI 写作", href: "/page/workspace/ai-writing", icon: PenLine, match: "/page/workspace/ai-writing" },
{
label: "多智能体规划",
href: "/page/workspace/roundtable/planning",
icon: UsersRound,
match: "/page/workspace/roundtable",
},
{
label: "定时任务",
href: "/page/workspace/scheduled/tasks",
icon: CalendarClock,
match: "/page/workspace/scheduled",
},
{ label: "问答梳理", href: "/page/workspace/knowledge", icon: BookOpenCheck, match: "/page/workspace/knowledge" },
] as const;
export function CmChatSidebar({
onCollapse,
collapsed = false,
forceCollapsed = false,
}: {
onCollapse?: () => void;
collapsed?: boolean;
/** Deployment-level compact navigation: retain only accessible menu icons. */
forceCollapsed?: boolean;
}) {
const pathname = usePathname();
const navigate = useNavigate();
const { theme } = useTheme();
const brandLogoSrc = theme === "light" ? cmAssistantLogoLight : cmAssistantLogoDark;
const handleNewChat = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault();
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
};
if (collapsed) {
return (
<aside className="flex h-full min-h-0 w-14 flex-col border-r border-slate-200/80 bg-white text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-200">
{!forceCollapsed && (
<>
<button
type="button"
onClick={onCollapse}
aria-label="展开侧边栏"
title="展开侧边栏"
className="mx-auto mt-2 flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900"
>
<img
src={brandLogoSrc}
alt="CM 助手"
className="size-10 object-contain"
/>
</button>
<div className="mx-2 mt-2 h-px shrink-0 bg-slate-200/80 dark:bg-slate-800" />
</>
)}
<nav
className={cn(
"flex shrink-0 flex-col items-center gap-1 px-2 py-3",
forceCollapsed && "pt-4",
)}
aria-label="工作台导航"
>
{NAV_ITEMS.map((item) => {
const active = item.href === NEW_CHAT_PATH
? pathname === NEW_CHAT_PATH
: item.href === "/page/workspace/chats"
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
: pathname.startsWith(item.match);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
aria-current={active ? "page" : undefined}
aria-label={item.label}
title={item.label}
className={cn(
"flex size-10 items-center justify-center rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
active
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{item.href === "/page/workspace/agents" ? (
<AgentBrandIcon size="nav" />
) : (
<Icon className="size-[19px]" strokeWidth={1.8} />
)}
<span className="sr-only">{item.label}</span>
</Link>
);
})}
</nav>
<div className="mt-auto flex shrink-0 justify-center border-t border-slate-200/80 py-3 dark:border-slate-800">
<Link
href="/page/workspace/settings"
aria-label="设置和更多"
title="设置和更多"
className="flex size-10 items-center justify-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"
>
<Settings className="size-[19px]" strokeWidth={1.8} />
</Link>
</div>
</aside>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden border-r border-slate-200/80 bg-white text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100">
<div className="flex shrink-0 items-center gap-1.5 px-4 pt-3.5 pb-2.5">
<Link href={NEW_CHAT_PATH} className="flex min-w-0 flex-1 items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-primary/40">
<img
src={brandLogoSrc}
alt="CM 助手"
className="size-9 shrink-0 object-contain"
/>
<span className="truncate text-[19px] font-semibold tracking-[-0.025em]">CM 助手</span>
</Link>
<button
type="button"
onClick={onCollapse}
aria-label="收起侧边栏"
title="收起侧边栏"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900 dark:hover:text-white"
>
<PanelLeftClose className="size-[18px]" strokeWidth={1.8} />
</button>
</div>
<nav className="shrink-0 px-3 pt-1" aria-label="工作台导航">
<div className="space-y-1">
{NAV_ITEMS.map((item) => {
const active = item.href === NEW_CHAT_PATH
? pathname === NEW_CHAT_PATH
: item.href === "/page/workspace/chats"
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
: pathname.startsWith(item.match);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
aria-current={active ? "page" : undefined}
className={cn(
"flex h-10 items-center gap-3 rounded-xl px-3 text-[14px] font-medium transition-colors",
active
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{item.href === "/page/workspace/agents" ? (
<AgentBrandIcon size="nav" />
) : (
<Icon className="size-[18px] shrink-0" strokeWidth={1.8} />
)}
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-1">
<RecentChatList compact limit={5} moreHref="/page/workspace/chats" />
</div>
<div className="shrink-0 border-t border-slate-200/80 px-3 py-3 dark:border-slate-800">
<WorkspaceNavMenu />
</div>
</div>
);
}
@@ -19,16 +19,17 @@ const TOOLBOX_ICON_TONES = [
* 简洁模式右侧工具箱。
*
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
* 放到 ``toolbox`` 泳道。默认展开常显(不再藏进右侧按钮后面);点 X 或 Esc
* 收起后右侧出现竖排按钮,可再次展开。
* 放到 ``toolbox`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。
*/
export function CompactToolbox() {
const { toolboxItems } = useWorkspaceCompactNavItems();
const { pathname } = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(true);
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => setOpen(false), [pathname]);
useEffect(() => {
if (!open) return;
@@ -40,6 +41,7 @@ export function CompactToolbox() {
};
document.addEventListener("keydown", handleKeyDown);
closeRef.current?.focus();
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
@@ -71,8 +73,22 @@ export function CompactToolbox() {
<span className="text-xs font-semibold leading-4">工具箱</span>
</button>
<button
type="button"
tabIndex={-1}
aria-label="关闭工具箱抽屉"
aria-hidden={!open}
onClick={closeDrawer}
className={cn(
"fixed inset-0 z-[90] bg-black/20 transition-opacity duration-200",
open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
)}
/>
<aside
id="compact-toolbox-panel"
role="dialog"
aria-modal={open}
aria-label="工具箱"
aria-hidden={!open}
inert={!open}
@@ -86,9 +102,10 @@ export function CompactToolbox() {
<header className="relative flex h-12 shrink-0 items-center justify-center px-2">
<h2 className="text-sm font-semibold">工具箱</h2>
<button
ref={closeRef}
type="button"
aria-label="收起工具箱"
title="收起"
aria-label="关闭工具箱"
title="关闭"
onClick={closeDrawer}
className="absolute right-2 flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -114,7 +131,10 @@ export function CompactToolbox() {
? "bg-primary/12 text-primary ring-1 ring-inset ring-primary/25"
: "bg-muted/55 text-card-foreground hover:bg-primary/8 hover:text-primary",
)}
onClick={() => navigate(item.path ?? "/page/workspace/chats/new")}
onClick={() => {
setOpen(false);
navigate(item.path ?? "/page/workspace/chats/new");
}}
>
<span
className={cn(
@@ -53,10 +53,12 @@ export function CustomPromptBar({
onInsert,
className,
currentAgentId,
compact = false,
}: {
onInsert: (content: string) => void;
className?: string;
currentAgentId?: string | null;
compact?: boolean;
}) {
const allPrompts = useCustomPrompts();
const prompts = allPrompts.filter(
@@ -102,7 +104,11 @@ export function CustomPromptBar({
return (
<>
<div
className={cn("flex min-w-0 items-center gap-1", className)}
className={cn(
"flex min-w-0 items-center gap-1",
compact && "min-h-9 justify-end",
className,
)}
data-compact-guide="prompt-bar"
>
{prompts.length > 0 && (
@@ -134,6 +140,7 @@ export function CustomPromptBar({
title={prompt.content}
className={cn(
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
compact && "border-slate-200 bg-white px-3.5 py-1.5 text-[12px] text-slate-600 shadow-none hover:border-primary/25 hover:bg-primary/5 hover:text-primary",
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
)}
>
@@ -162,7 +169,11 @@ export function CustomPromptBar({
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full",
compact &&
"h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600 shadow-[0_6px_18px_-14px_rgba(15,23,42,0.4)] hover:border-primary/25 hover:bg-primary/5 hover:text-primary dark:border-slate-700 dark:bg-slate-900",
)}
title="管理常用提示词"
aria-label="管理常用提示词"
onClick={() => setManageOpen(true)}
@@ -7,8 +7,7 @@
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
*
* 可挂在 workspace / strategy 两个普通问答路由下;导航目标由 RouteBaseProvider
* 决定。三块结构(自上而下):
* 三块结构(自上而下):
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
@@ -86,7 +85,6 @@ import {
titleOfThread,
} from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
@@ -95,6 +93,11 @@ import { cn } from "@/lib/utils";
import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
/** 「更多」常用智能体跳转目标:智能体管理页。 */
const MORE_AGENTS_HREF = "/page/workspace/agents";
/** 「更多最近对话」跳转目标。 */
const MORE_CHATS_HREF = "/page/workspace/chats";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
@@ -139,7 +142,6 @@ function SectionLabel({
export function GeneralQaSidebar() {
const { t } = useI18n();
const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
@@ -186,14 +188,9 @@ export function GeneralQaSidebar() {
const searchParams = useSearchParams();
const activeAgentId = searchParams.get("agent");
const currentSearch = searchParams.toString();
const chatsBasePath = `${routeBase}/chats`;
const newChatPath = `${chatsBasePath}/new`;
const newChatHref = buildFullChatNavigationPath(newChatPath, currentSearch);
const moreAgentsHref = buildFullChatNavigationPath(
`${routeBase}/agents`,
currentSearch,
);
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
@@ -202,18 +199,19 @@ export function GeneralQaSidebar() {
(agentId: string) => {
navigate(
buildFullChatNavigationPath(
routeBase === "/page/workspace"
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
currentSearch,
),
);
},
[currentSearch, navigate, newChatPath, routeBase],
[currentSearch, navigate],
);
// ── 最近的对话取数 + 搜索 ──
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
thread_id: string;
agent_id?: string;
}>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
@@ -226,14 +224,6 @@ export function GeneralQaSidebar() {
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
const pathForThread = useCallback(
(thread: AgentThread) =>
isBootstrapThread(thread)
? listPathOfThread(thread)
: pathOfThread(thread, undefined, routeBase),
[routeBase],
);
const visibleThreads = useMemo(
() =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
@@ -264,22 +254,21 @@ export function GeneralQaSidebar() {
deleteThread({ threadId });
if (threadId === threadIdFromPath) {
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
let nextThreadPath = newChatPath;
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = pathForThread(threads[threadIndex + 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) {
nextThreadPath = pathForThread(threads[threadIndex - 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
}
}
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
}
},
[
agentIdFromPath,
currentSearch,
deleteThread,
newChatPath,
pathForThread,
router,
threadIdFromPath,
threads,
@@ -478,7 +467,7 @@ export function GeneralQaSidebar() {
) : (
filteredThreads.map((thread) => {
const href = buildFullChatNavigationPath(
pathForThread(thread),
listPathOfThread(thread),
currentSearch,
);
const isActive = href === pathname;
@@ -1,16 +1,20 @@
"use client";
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
GraduationCapIcon,
LightbulbIcon,
import type { ChatStatus } from "ai";
import {
BarChart3Icon,
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
ClipboardCheckIcon,
FileTextIcon,
FolderTreeIcon,
GraduationCapIcon,
LightbulbIcon,
MessageCircleQuestionIcon,
PaperclipIcon,
PlusIcon,
RefreshCwIcon,
ShrinkIcon,
SparklesIcon,
RocketIcon,
@@ -106,6 +110,56 @@ import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
const COMPACT_QUICK_STARTS = [
{
id: "report",
title: "撰写报告",
content: "请帮我撰写一份结构清晰的专业报告,先向我确认主题、受众和篇幅要求。",
icon: FileTextIcon,
},
{
id: "knowledge",
title: "知识问答",
content: "请基于已有资料回答我的问题,并明确区分已知事实、推断和需要补充的信息。",
icon: MessageCircleQuestionIcon,
},
{
id: "planning",
title: "任务规划",
content: "请把我的目标拆解为可执行的任务计划,列出优先级、里程碑和风险。",
icon: ClipboardCheckIcon,
},
{
id: "analysis",
title: "数据分析",
content: "请帮我分析数据中的关键趋势、异常和可能原因,并给出下一步验证建议。",
icon: BarChart3Icon,
},
{
id: "solution",
title: "方案策划",
content: "请围绕我的目标设计一套完整方案,包含背景、目标、实施步骤、资源需求和风险预案。",
icon: LightbulbIcon,
},
{
id: "summary",
title: "内容总结",
content: "请提炼我提供内容的核心观点、关键结论和待办事项,并按重要程度整理。",
icon: BookMarkedIcon,
},
{
id: "research",
title: "调研提纲",
content: "请根据我的调研主题生成一份结构完整的调研提纲,覆盖对象、问题、方法和输出形式。",
icon: GraduationCapIcon,
},
{
id: "polish",
title: "文案润色",
content: "请在不改变原意的前提下润色我提供的内容,使表达更清晰、专业、简洁。",
icon: SparklesIcon,
},
] as const;
function getResolvedMode(
@@ -143,7 +197,6 @@ export function InputBox({
hideAdminRecommendedQuestions,
compact,
hideContextTools,
hideMemoryTool = true,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -154,11 +207,6 @@ export function InputBox({
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -241,6 +289,7 @@ export function InputBox({
const wasStreamingRef = useRef(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [compactPromptOrder, setCompactPromptOrder] = useState(0);
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null,
);
@@ -251,6 +300,29 @@ export function InputBox({
!hideAdminRecommendedQuestions &&
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
const compactQuickStarts = useMemo(() => {
if (showAdminRecommendedQuestions) {
return recommendedQuestions.map((configured, index) => {
const fallback =
COMPACT_QUICK_STARTS[index % COMPACT_QUICK_STARTS.length]!;
return {
...fallback,
id: configured.id,
title: desensitize(configured.title) || `提示词 ${index + 1}`,
content: configured.content || fallback.content,
};
});
}
return [...COMPACT_QUICK_STARTS];
}, [recommendedQuestions, showAdminRecommendedQuestions]);
const orderedCompactQuickStarts = useMemo(() => {
if (compactQuickStarts.length < 2) return compactQuickStarts;
const offset = compactPromptOrder % compactQuickStarts.length;
return [
...compactQuickStarts.slice(offset),
...compactQuickStarts.slice(0, offset),
];
}, [compactPromptOrder, compactQuickStarts]);
useEffect(() => {
// Multi-round RAG is a frontend execution profile, not a model option.
@@ -615,14 +687,53 @@ export function InputBox({
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
<div ref={promptRootRef} className={cn("relative flex flex-col", compact ? "gap-[clamp(32px,6vh,58px)]" : "gap-6")}>
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
{isNewThread && searchParams.get("mode") !== "skill" && compact && (
<section className="px-2" aria-labelledby="recommended-prompts-title">
<div className="mb-3 flex items-center justify-center gap-1.5">
<h2 id="recommended-prompts-title" className="text-[14px] font-normal text-slate-400 dark:text-slate-500">
你可以这样问我
</h2>
<button
type="button"
data-prompt-order-refresh="true"
title="调整提示词顺序"
aria-label="调整提示词顺序"
onClick={() => setCompactPromptOrder((current) => current + 1)}
className="flex size-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:hover:bg-slate-800 dark:hover:text-primary"
>
<RefreshCwIcon className="size-3.5" strokeWidth={1.8} />
</button>
</div>
<div className="mx-auto flex max-w-[900px] flex-wrap justify-center gap-x-3 gap-y-3">
{orderedCompactQuickStarts.map((item) => (
<button
key={item.id}
type="button"
data-quick-prompt={item.id}
title={item.content}
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-xl border border-[#e5e7eb] bg-white px-4 py-2 text-center text-[14px] font-normal leading-5 text-slate-700 shadow-[0_2px_6px_rgba(15,23,42,0.025)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/5 hover:text-slate-900 hover:shadow-[0_4px_10px_rgba(15,23,42,0.04)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-primary/35 dark:hover:bg-primary/10"
onClick={() => {
textInput.setInput(item.content);
requestAnimationFrame(() =>
document.querySelector<HTMLTextAreaElement>("textarea[name='message']")?.focus(),
);
}}
>
<span className="max-w-full whitespace-normal">{item.title}</span>
</button>
))}
</div>
</section>
)}
{isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -675,7 +786,8 @@ export function InputBox({
<div className="flex flex-col gap-1.5">
{isNewThread && (
<CustomPromptBar
className="px-1"
compact={compact}
className={cn(compact ? "px-2" : "px-1")}
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
onInsert={(content) => {
const current = textInput.value?.trim() ?? "";
@@ -699,7 +811,7 @@ export function InputBox({
: "*:data-[slot='input-group']:rounded-3xl",
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
compact && isNewThread
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(15,23,42,0.22),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-primary/35 focus-within:ring-4 focus-within:ring-primary/10 dark:border-slate-700 dark:bg-slate-800"
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
@@ -723,7 +835,7 @@ export function InputBox({
className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
isNewThread && "min-h-[110px]!",
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
compact && "min-h-[clamp(150px,18vh,176px)]! px-7 pt-7 pb-2 text-[15px]",
)}
disabled={disabled}
placeholder={t.inputBox.placeholder}
@@ -734,7 +846,7 @@ export function InputBox({
<PromptInputFooter
className={cn(
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3",
compact && "gap-2 px-6 pt-2 pb-5",
)}
>
<PromptInputTools
@@ -758,7 +870,7 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && !hideContextTools && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -813,7 +925,7 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -1,57 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, Database } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth";
import {
loadKnowledgeBaseProductVersion,
type KnowledgeBaseProductVersion,
updateKnowledgeBaseProductVersion,
} from "@/core/system-settings/api";
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
export function useKnowledgeBaseProductVersion() {
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
}
export function KnowledgeProductNav() {
const location = useLocation();
const navigate = useNavigate();
const client = useQueryClient();
const version = useKnowledgeBaseProductVersion();
const isAdmin = getStoredAuth()?.system_role === "admin";
const save = useMutation({
mutationFn: updateKnowledgeBaseProductVersion,
onSuccess: (value) => {
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
toast.success(`默认知识库体验已切换为 ${value}`);
},
});
const assistant = location.pathname.includes("/knowledge/assistant");
return (
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
{isAdmin && (
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
默认产品版本
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
</Select>
</div>
)}
</div>
);
}
@@ -22,10 +22,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
type AssistantKnowledgeBase,
useAssistantKnowledgeBases,
} from "@/strategy-components/api/assistant-knowledge";
import {
type LlmWikiKnowledgeBase,
useAgentLlmWikiKnowledgeBases,
@@ -70,26 +66,10 @@ function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
);
}
function AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) {
return (
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
item.is_global ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-sky-500/10 text-sky-600 dark:text-sky-400",
)}
>
<Database className="size-3.5" />
</span>
);
}
function SelectionContent({
rows,
assistantRows,
value,
assistantValue,
onChange,
onAssistantChange,
search,
onSearchChange,
loading,
@@ -99,11 +79,8 @@ function SelectionContent({
readOnly,
}: {
rows: LlmWikiKnowledgeBase[];
assistantRows: AssistantKnowledgeBase[];
value: string[];
assistantValue: string[];
onChange: (value: string[]) => void;
onAssistantChange: (value: string[]) => void;
search: string;
onSearchChange: (value: string) => void;
loading: boolean;
@@ -113,7 +90,6 @@ function SelectionContent({
readOnly: boolean;
}) {
const selected = useMemo(() => new Set(value), [value]);
const selectedAssistant = useMemo(() => new Set(assistantValue), [assistantValue]);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return rows;
@@ -121,28 +97,11 @@ function SelectionContent({
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
);
}, [rows, search]);
const filteredAssistant = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return assistantRows;
return assistantRows.filter((item) =>
`${item.name} ${item.description ?? ""}`.toLocaleLowerCase().includes(query),
);
}, [assistantRows, search]);
const groups = useMemo(() => groupRows(filtered), [filtered]);
const toggle = (id: string) => {
if (readOnly) return;
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
};
const toggleAssistant = (id: string) => {
if (readOnly) return;
onAssistantChange(
selectedAssistant.has(id)
? assistantValue.filter((item) => item !== id)
: [...assistantValue, id],
);
};
const selectedCount = value.length + assistantValue.length;
const hasRows = groups.length > 0 || filteredAssistant.length > 0;
return (
<>
@@ -168,7 +127,7 @@ function SelectionContent({
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{error instanceof Error ? error.message : "知识空间读取失败"}
</div>
) : !hasRows ? (
) : groups.length === 0 ? (
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
{search
? "没有匹配的知识空间"
@@ -177,181 +136,91 @@ function SelectionContent({
: "当前账号暂无可用知识空间"}
</div>
) : (
<>
{groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={item.id}
type="button"
role="checkbox"
aria-checked={selected.has(item.id)}
disabled={readOnly}
onClick={() => toggle(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))}
{filteredAssistant.length > 0 && (
<div>
{(groups.length > 0) && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
<Database className="size-3" />助手知识库
</DropdownMenuLabel>
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
<Database className="size-3" />助手知识库
</div>
)}
{filteredAssistant.map((item) => {
const rowContent = (
<>
<AssistantSpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体
</div>
</div>
<Badge variant="outline" className="h-4 px-1 text-[9px]">
{item.is_global ? "总库" : "助手"}
</Badge>
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={`assistant:${item.id}`}
checked={selectedAssistant.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggleAssistant(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={`assistant:${item.id}`}
type="button"
role="checkbox"
aria-checked={selectedAssistant.has(item.id)}
disabled={readOnly}
onClick={() => toggleAssistant(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selectedAssistant.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selectedAssistant.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selectedAssistant.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
)}
</>
<button
key={item.id}
type="button"
role="checkbox"
aria-checked={selected.has(item.id)}
disabled={readOnly}
onClick={() => toggle(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))
)}
</div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
</span>
{readOnly ? (
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
) : (
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
onChange(filtered.map((item) => item.id));
onAssistantChange(filteredAssistant.map((item) => item.id));
}}
>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
全选
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
onChange([]);
onAssistantChange([]);
}}
>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
清空
</Button>
</div>
@@ -363,9 +232,7 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({
value,
assistantValue = [],
onChange,
onAssistantChange = () => undefined,
source = "all",
agentId,
autoSelectAll = false,
@@ -376,9 +243,7 @@ export function LlmWikiKnowledgeSelector({
variant = "popover",
}: {
value: string[];
assistantValue?: string[];
onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
@@ -392,39 +257,28 @@ export function LlmWikiKnowledgeSelector({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const all = useLlmWikiKnowledgeBases(
"all",
weknoraEnabled,
runtime.data?.provider === "weknora",
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
weknoraEnabled && source === "agent" && !!agentId,
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
);
const assistantBases = useAssistantKnowledgeBases();
const rows = weknoraEnabled ? (all.data ?? []) : [];
const assistantRows = assistantBases.data ?? [];
const rows = all.data ?? [];
const defaultIds = useMemo(() => {
if (source !== "agent") {
return rows.map((item) => item.id);
}
return (agent.data ?? []).map((item) => item.id);
}, [agent.data, rows, source]);
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = all.error ?? (source === "agent" ? agent.error : null);
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
const assistantAvailableIds = useMemo(
() => new Set(assistantRows.map((item) => item.id)),
[assistantRows],
);
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null);
useEffect(() => {
if (!runtime.isLoading && !weknoraEnabled) {
if (value.length > 0) onChange([]);
return;
}
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
initialized.current = initializationKey;
const valid = value.filter((id) => availableIds.has(id));
@@ -434,30 +288,19 @@ export function LlmWikiKnowledgeSelector({
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
onChange(next);
}
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
useEffect(() => {
if (!assistantBases.data) return;
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
onAssistantChange(valid);
}
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
const content = (
<SelectionContent
rows={rows}
assistantRows={assistantRows}
value={value}
assistantValue={assistantValue}
onChange={onChange}
onAssistantChange={onAssistantChange}
search={search}
onSearchChange={setSearch}
loading={loading}
@@ -480,7 +323,7 @@ export function LlmWikiKnowledgeSelector({
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
</div>
{content}
</div>
@@ -490,15 +333,10 @@ export function LlmWikiKnowledgeSelector({
const selectedNames = value
.map((id) => rows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const selectedAssistantNames = assistantValue
.map((id) => assistantRows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
const selectedCount = value.length + assistantValue.length;
const selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、")
: selectedCount > 0
? `已选 ${selectedCount} 个知识空间`
const selectedLabel = selectedNames.length > 0
? selectedNames.join("、")
: value.length > 0
? `已选 ${value.length} 个知识空间`
: label;
return (
@@ -510,20 +348,20 @@ export function LlmWikiKnowledgeSelector({
size="sm"
className={cn(
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
className,
)}
disabled={disabled || loading}
title={selectedCount > 0 ? selectedLabel : undefined}
title={value.length > 0 ? selectedLabel : undefined}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
<span className="truncate text-xs font-normal">{selectedLabel}</span>
{selectedCount > 0 && (
{value.length > 0 && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
{selectedCount}
{value.length}
</span>
)}
{selectedCount > 0 && selectedCount === rows.length + assistantRows.length && <Check className="size-3" />}
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
@@ -40,10 +40,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
@@ -53,13 +51,12 @@ import {
resolveSearchQuery,
type SearchResultItem,
} from "@/core/messages/search-results";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useSkills } from "@/core/skills/hooks";
import {
applyInlineCitations,
buildSkillDisplayMap,
@@ -70,7 +67,6 @@ import {
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -79,7 +75,10 @@ import { useArtifactsOptional } from "../artifacts";
import { CitationLink } from "../citations/citation-link";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import {
isWeKnoraSourcePreviewUrl,
SourceCard,
} from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
@@ -96,7 +95,7 @@ export function MessageGroup({
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
}) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
@@ -104,19 +103,12 @@ export function MessageGroup({
const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [settings] = useLocalSettings();
// 写作模式的「拦截 outputs/*.md 手写」只在写作模式生效;普通模式的
// write_file / read_file 步骤条必须照旧显示。
const writingModeEnabled = !!settings.context.writing_mode;
const steps = useMemo(
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
const { skills } = useSkills();
const steps = useMemo(() => convertToSteps(messages), [messages]);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1];
@@ -208,11 +200,11 @@ export function MessageGroup({
<ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled}
/>
),
/>
),
)}
{lastToolCallStep && (
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
@@ -220,10 +212,10 @@ export function MessageGroup({
key={lastToolCallStep.id}
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled}
/>
/>
</FlipDisplay>
)}
</ChainOfThoughtContent>
@@ -765,6 +757,7 @@ function ArtifactWriteToolCall({
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
@@ -803,21 +796,19 @@ function ArtifactWriteToolCall({
!artifactPath ||
!previewKey ||
!isLoading ||
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
sourcePath,
]);
return (
@@ -859,7 +850,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
function convertToSteps(messages: Message[]): CoTStep[] {
const steps: CoTStep[] = [];
for (const message of messages) {
if (message.type === "ai") {
@@ -877,22 +868,6 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
if (tool_call.name === "task") {
continue;
}
const writePath =
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
? (tool_call.args as { path: string }).path
: "";
if (
(
tool_call.name === "write_file" ||
tool_call.name === "str_replace" ||
tool_call.name === "read_file" ||
tool_call.name === "read_multiple_files" ||
tool_call.name === "present_files"
) &&
isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
) {
continue;
}
const step: CoTToolCallStep = {
id: tool_call.id,
messageId: message.id,
@@ -973,19 +948,43 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
);
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
const date = item.published_date ?? item.publishedDate ?? item.date;
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
const date = item.published_date ?? item.publishedDate ?? item.date;
let sourceLabel: string | undefined = item.source;
if (item.url) {
try {
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
} catch {
sourceLabel = sourceLabel ?? item.url;
}
}
return (
} catch {
sourceLabel = sourceLabel ?? item.url;
}
}
// MCP retrieval tools without a configured display adapter fall back to this
// generic result card. Preserve the normal web-search UI, but route our
// credential-free WeKnora pointer through SourceCard so its dialog exposes a
// deliberate “查看原文” action rather than rendering the internal route text.
if (isWeKnoraSourcePreviewUrl(item.url)) {
const weknoraSource: RuntimeDisplaySource = {
id: item.id ?? item.uuid ?? item.docId,
title: item.title ?? "检索结果",
type: item.source ?? "document",
snippet: item.snippet ?? item.content ?? "",
content,
source: item.source,
author: item.author,
time: date,
url: item.url,
index: 0,
skillName: "weknora-knowledge-search",
mode: "list",
raw: { ...item },
};
return <SourceCard source={weknoraSource} />;
}
return (
<>
<button
type="button"
@@ -0,0 +1,267 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
import { isIMEComposing } from "@/lib/ime";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
function openDraft(
draft: DraftMeta,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
window.dispatchEvent(
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
);
} else {
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
sessionStorage.setItem("roundtable:pending-draft", draft.id);
router.push(ROUNDTABLE_PATH);
}
}
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
function StatusBadge({ draft }: { draft: DraftMeta }) {
if (draft.status === "running") {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
进行中
</span>
);
}
if (draft.status === "awaiting_input") {
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
}
if (draft.status === "error") {
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
}
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
return (
<span className="text-[10px] font-medium text-muted-foreground">
{draft.chainTitle || "自由模式"}
</span>
);
}
export function RecentRoundtableList() {
const router = useRouter();
const pathname = usePathname();
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
);
// 重命名对话框状态。
const [renameId, setRenameId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// 分享对话框 —— 正在分享的草稿 id。
const [shareId, setShareId] = useState<string | null>(null);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
const handleRenameSubmit = useCallback(async () => {
const id = renameId;
const title = renameValue.trim();
if (!id || !title) {
setRenameId(null);
return;
}
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
setRenameValue("");
}
}, [renameId, renameValue, rename]);
const handleDelete = useCallback(
async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
toast.success("已删除会商记录");
} catch {
toast.error("删除失败,请重试");
}
},
[remove],
);
if (!loading && drafts.length === 0) return null;
const isOnPage = pathname === ROUNDTABLE_PATH;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>多智能体会商</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && drafts.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
drafts.slice(0, 10).map((draft) => (
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openDraft(draft, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{draft.title || "未命名草稿"}
</span>
<div className="flex items-center gap-1 mt-0.5">
<StatusBadge draft={draft} />
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background top-2"
>
<MoreHorizontal />
<span className="sr-only">更多</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-44 rounded-lg"
side="right"
align="start"
>
<DropdownMenuItem
onSelect={() => {
setRenameId(draft.id);
setRenameValue(draft.title || "");
}}
>
<Pencil className="text-muted-foreground" />
<span>重命名</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
<Share2 className="text-muted-foreground" />
<span>分享</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
<Trash2 className="text-muted-foreground" />
<span>删除</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* 重命名对话框 */}
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>重命名会商</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameId(null)}>
取消
</Button>
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享对话框 */}
<ShareRoundtableDraftDialog
draftId={shareId}
open={shareId !== null}
onOpenChange={(o) => {
if (!o) setShareId(null);
}}
/>
</>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
function openSession(
session: AIWritingSessionSummary,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在 AI 写作页面,直接通过自定义事件通知面板
window.dispatchEvent(
new CustomEvent("ai-writing:open-session", { detail: session }),
);
} else {
// 不在该页面,把 session 存入 sessionStorage 后跳转
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
router.push(AI_WRITING_PATH);
}
}
export function RecentWritingList() {
const router = useRouter();
const pathname = usePathname();
const { sessions, loading, refresh } = useAIWritingSessions();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
if (!loading && sessions.length === 0) return null;
const isOnPage = pathname === AI_WRITING_PATH;
return (
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>最近写作</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && sessions.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
sessions.slice(0, 10).map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<SidebarMenuItem key={session.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openSession(session, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{session.title}
</span>
<div className="flex items-center gap-1 mt-0.5">
<span className={cn("text-[10px] font-medium", color)}>
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(session.updated_at)}
</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -25,7 +25,8 @@ import {
Trash2Icon,
TrendingUpIcon,
Upload,
UserIcon,
UserIcon,
WorkflowIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -50,13 +51,6 @@ import {
EmptyTitle,
} from "@/components/ui/empty";
import {
Item,
ItemActions,
ItemTitle,
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -93,7 +87,6 @@ import {
useViewMode,
} from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
import { formatDateTime } from "@/core/utils/datetime";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
@@ -602,7 +595,6 @@ export function SkillRow({
isAdmin,
layout = "row",
onPublisherClick,
knowledgeBindings = [],
}: {
skill: Skill;
currentUserId: string | null;
@@ -612,8 +604,6 @@ export function SkillRow({
/** Click the 发布者 line to filter the list to that publisher. Receives the
* owner_user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
/** Admin list can inject its single batched binding query here (never N+1). */
knowledgeBindings?: SkillKnowledgeBinding[];
}) {
const isCard = layout === "card";
const { t } = useI18n();
@@ -760,7 +750,7 @@ export function SkillRow({
const badgeGroup = (
<div className="flex flex-wrap items-center gap-2">
<span className={cn(isCard && "truncate font-medium")}>
<span className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-100">
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
</span>
{skill.name_zh?.trim() && (
@@ -862,82 +852,15 @@ export function SkillRow({
常驻
</Badge>
)}
{knowledgeBindings.slice(0, 3).map((binding) => (
<Badge
key={binding.id}
variant="outline"
className="border-indigo-500/30 text-indigo-700 dark:text-indigo-300 text-xs"
title={`${binding.target_name} · ${binding.status}${binding.remote_business_key ? ` · ${binding.remote_business_key}` : ""}`}
>
知识库:{binding.target_name || binding.target_id}
{binding.status === "stale" ? "(待更新)" : ""}
</Badge>
))}
{knowledgeBindings.length > 3 && (
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
)}
</div>
);
const metaNodes = (
<>
{!isBuiltin && skill.owner_username && (
<button
type="button"
disabled={!skill.owner_user_id || !onPublisherClick}
onClick={(e) => {
if (!skill.owner_user_id || !onPublisherClick) return;
e.stopPropagation();
onPublisherClick({
id: skill.owner_user_id,
label: desensitize(skill.owner_username!),
});
}}
className={cn(
"text-muted-foreground mt-1 flex max-w-full items-center gap-1 text-sm font-medium",
skill.owner_user_id && onPublisherClick &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
skill.owner_user_id && onPublisherClick
? `点击筛选发布者:${desensitize(skill.owner_username)}`
: `发布者:${desensitize(skill.owner_username)}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)}
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
)}
{formatDateTime(skill.created_at) && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<ClockIcon className="size-3.5 shrink-0" />
<span className="truncate">
创建时间:{formatDateTime(skill.created_at)}
</span>
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</>
);
const actionButtons = (
<>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
@@ -948,7 +871,7 @@ export function SkillRow({
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.featured && "text-amber-600 hover:text-amber-700",
)}
disabled={setFeatured.isPending}
@@ -969,7 +892,7 @@ export function SkillRow({
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
@@ -990,7 +913,7 @@ export function SkillRow({
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.always_on && "text-amber-600 hover:text-amber-700",
)}
disabled={setAlwaysOn.isPending}
@@ -1010,7 +933,7 @@ export function SkillRow({
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
@@ -1029,7 +952,7 @@ export function SkillRow({
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
@@ -1040,7 +963,7 @@ export function SkillRow({
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
@@ -1049,39 +972,125 @@ export function SkillRow({
)}
</>
);
const iconStyle = skill.name.includes("web") || skill.name.includes("search")
? {
Icon: GlobeIcon,
className: "bg-emerald-600 text-white shadow-[0_6px_16px_rgba(22,163,74,0.22)]",
}
: skill.name.includes("doc") || skill.name.includes("file")
? {
Icon: FileArchive,
className: "bg-violet-600 text-white shadow-[0_6px_16px_rgba(124,58,237,0.22)]",
}
: skill.source === "agent"
? {
Icon: WorkflowIcon,
className: "bg-[#2166e8] text-white shadow-[0_6px_16px_rgba(37,99,235,0.22)]",
}
: {
Icon: Upload,
className: "bg-amber-500 text-white shadow-[0_6px_16px_rgba(245,158,11,0.22)]",
};
const SkillIcon = iconStyle.Icon;
const activityAt = formatDateTime(skill.last_activity_at ?? skill.created_at);
const actionArea = (
<div
className="flex flex-wrap items-center justify-end gap-1"
onClick={(event) => event.stopPropagation()}
>
{actionButtons}
</div>
);
return (
<>
{isCard ? (
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex h-full min-w-0 flex-col rounded-xl border border-slate-200 bg-white p-5 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)}
>
{badgeGroup}
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
{desensitize(skill.description ?? "")}
</p>
{metaNodes}
<div className="flex items-start gap-3.5">
<div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
<SkillIcon className="size-6" />
</div>
<div className="min-w-0 flex-1">
{badgeGroup}
<p className="mt-1.5 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
{desensitize(skill.description ?? "")}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
{skill.enabled ? "已启用" : "未启用"}
</Badge>
<span className="text-xs text-slate-400">{skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"}</span>
{activityAt && <span className="text-xs text-slate-400">更新于 {activityAt}</span>}
</div>
{(skill.tags?.length ?? 0) > 0 && <TagChipList tags={skill.tags!} className="mt-3" />}
</div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
{actionButtons}
<div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
{actionArea}
</div>
</div>
) : (
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<ItemTitle>{badgeGroup}</ItemTitle>
<ItemDescription className="line-clamp-4">
{desensitize(skill.description ?? "")}
</ItemDescription>
{metaNodes}
</ItemContent>
<ItemActions>{actionButtons}</ItemActions>
</Item>
<div className="group/skill-card w-full rounded-xl border border-slate-200 bg-white px-5 py-4 shadow-[0_5px_18px_rgba(15,23,42,0.03)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.65fr)_minmax(150px,0.72fr)_minmax(100px,0.42fr)_minmax(132px,0.58fr)_auto] xl:items-center xl:gap-x-6">
<div className="flex min-w-0 cursor-pointer items-start gap-4" onClick={() => setDetailOpen(true)}>
<div className={cn("flex size-[60px] shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
<SkillIcon className="size-7" />
</div>
<div className="min-w-0 flex-1 pt-0.5">
{badgeGroup}
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
{desensitize(skill.description ?? "")}
</p>
{!isBuiltin && skill.owner_username && (
<button
type="button"
disabled={!skill.owner_user_id || !onPublisherClick}
onClick={(event) => {
event.stopPropagation();
if (!skill.owner_user_id || !onPublisherClick) return;
onPublisherClick({ id: skill.owner_user_id, label: desensitize(skill.owner_username!) });
}}
className={cn(
"mt-2 flex max-w-full items-center gap-1 text-xs text-slate-400",
skill.owner_user_id && onPublisherClick && "cursor-pointer hover:text-blue-600 hover:underline",
)}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)}
</div>
</div>
<div className="min-w-0 xl:border-l xl:border-slate-100 xl:pl-5 xl:dark:border-slate-800">
{(skill.tags?.length ?? 0) > 0 ? (
<TagChipList tags={skill.tags!} />
) : (
<span className="text-xs text-slate-400">未设置标签</span>
)}
</div>
<div className="min-w-0 text-sm text-slate-600 dark:text-slate-300">
<span className="block text-xs text-slate-400 xl:hidden">使用次数</span>
{skill.use_count ? `${skill.use_count} 次` : "暂无调用"}
</div>
<div className="min-w-0">
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
{skill.enabled ? "已启用" : "未启用"}
</Badge>
{activityAt && <p className="mt-2 truncate text-xs text-slate-400" title={activityAt}>更新于 {activityAt}</p>}
</div>
<div className="min-w-0 border-t border-slate-100 pt-3 xl:border-t-0 xl:pt-0 dark:border-slate-800">
{actionArea}
</div>
</div>
</div>
)}
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
@@ -9,7 +9,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
parseWeKnoraPreviewUrl,
resolveWeKnoraSourceTarget,
} from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown";
@@ -18,6 +21,11 @@ import { useState } from "react";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
/** Whether a source URL is an internal WeKnora preview pointer. */
export function isWeKnoraSourcePreviewUrl(url?: string | null): boolean {
return parseWeKnoraPreviewUrl(url) !== null;
}
export function SkillDisplaySources({
highlightedReferenceId,
sources,
@@ -157,10 +165,10 @@ export function SourceCard({
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
isWikiSource
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
: "bg-primary/10 text-primary",
)}>
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
{isWikiSource ? "Wiki 文档" : "知识库文档"}
{isWikiSource ? "Wiki 文档" : "查看原文"}
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
@@ -1,261 +0,0 @@
import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
type SkillKnowledgeTarget,
useSkillKnowledgeActions,
useSkillKnowledgeBindings,
useSkillKnowledgeJobs,
} from "@/strategy-components/api/skill-knowledge";
const PHASE_LABELS: Record<string, string> = {
scanning: "扫描知识文件",
parsing: "解析资料",
extracting: "抽取实体关系",
awaiting_review: "等待审核",
merging: "合并实体关系",
writing_wiki: "写入 Wiki",
activating: "激活新版本",
completed: "完成",
};
export function SkillKnowledgePanel() {
const { skills, isLoading } = useAllSkillsAdmin(true);
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [skillQuery, setSkillQuery] = useState("");
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence");
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const bindings = useSkillKnowledgeBindings();
const jobs = useSkillKnowledgeJobs();
const actions = useSkillKnowledgeActions();
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
return (knowledgeBases.data ?? [])
.filter((kb) => {
const strategy = kb.indexing_strategy ?? {};
const capabilities = kb.capabilities ?? {};
const wikiEnabled =
kb.type === "wiki" ||
kb.wiki_index_enabled === true ||
strategy.wiki_enabled === true ||
capabilities.wiki_enabled === true ||
(kb.wiki_config && Object.keys(kb.wiki_config).length > 0);
return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled;
})
.map((kb) => ({
target_type: "weknora",
target_mode: "wiki",
target_id: kb.id,
target_name: kb.name,
remote_business_key: `weknora:kb:${kb.id}`,
}));
}, [knowledgeBases.data]);
const filteredSkills = useMemo(() => {
const needle = skillQuery.trim().toLowerCase();
if (!needle) return liveSkills;
return liveSkills.filter((skill) => {
const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" ");
const haystack = [
skill.name,
skill.name_zh,
skill.description,
skill.category,
tagText,
]
.filter(Boolean)
.join("\n")
.toLowerCase();
return haystack.includes(needle);
});
}, [liveSkills, skillQuery]);
const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]);
const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name));
const toggleFilteredSkills = () => {
if (!filteredSkillNames.length) return;
setSelectedSkills((current) => {
if (allFilteredSelected) {
const filtered = new Set(filteredSkillNames);
return current.filter((name) => !filtered.has(name));
}
return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))];
});
};
useEffect(() => {
if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) {
setSelectedTargetKey(null);
}
}, [selectedTargetKey, targets]);
const toggle = (values: string[], value: string, setter: (next: string[]) => void) =>
setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
const submit = async () => {
const selectedTargets = targets.filter((target) =>
selectedTargetKey === `${target.target_type}:${target.target_id}`,
);
if (!selectedSkills.length || !selectedTargets.length) {
toast.error("请至少选择一个技能和一个目标知识库");
return;
}
try {
const job = await actions.createJob.mutateAsync({
skill_names: selectedSkills,
targets: selectedTargets,
review_mode: reviewMode,
});
toast.success(`已创建 ${job.total_count} 个归纳项`);
setSelectedSkills([]);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
return (
<div className="grid size-full min-h-0 grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] gap-4 overflow-hidden p-6">
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
<div className="border-b p-4">
<h2 className="font-semibold">技能到知识库管理</h2>
<p className="mt-1 text-xs text-muted-foreground">一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。</p>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-4">
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">1. 选择技能({selectedSkills.length})</span>
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
</Button>
</div>
<div className="relative mb-2">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={skillQuery}
onChange={(event) => setSkillQuery(event.target.value)}
placeholder="按技能中文名、英文名、描述、分类或标签搜索"
className="pl-8"
/>
</div>
<div className="max-h-60 space-y-1 overflow-y-auto rounded-md border p-2">
{isLoading ? <Loader2 className="m-4 size-5 animate-spin" /> : filteredSkills.map((skill) => (
<label key={skill.name} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-muted">
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
</label>
))}
{!isLoading && !filteredSkills.length && (
<p className="p-3 text-xs text-muted-foreground">没有匹配的技能,请换个关键词。</p>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">2. 选择目标知识库({selectedTargetKey ? 1 : 0})</span>
</div>
<div className="space-y-1 rounded-md border p-2">
{targets.map((target) => {
const key = `${target.target_type}:${target.target_id}`;
return (
<label key={key} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 hover:bg-muted">
<Checkbox
checked={selectedTargetKey === key}
onCheckedChange={() => setSelectedTargetKey((current) => (current === key ? null : key))}
/>
<Database className="size-4 text-muted-foreground" />
<span className="text-sm">{target.target_name}</span>
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
</label>
);
})}
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
<p className="p-3 text-xs text-destructive">
读取 WeKnora 普通知识库失败:
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
</p>
)}
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora"
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">审核策略</span>
<Select value={reviewMode} onValueChange={(value) => setReviewMode(value as typeof reviewMode)}>
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto_high_confidence">高置信自动通过</SelectItem>
<SelectItem value="off">不阻塞写入</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" disabled={actions.createJob.isPending} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
开始归纳({selectedSkills.length * (selectedTargetKey ? 1 : 0)} 项)
</Button>
</div>
</ScrollArea>
</section>
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-4">
<div><h2 className="font-semibold">归纳任务</h2><p className="text-xs text-muted-foreground">后台执行、失败可单项重试</p></div>
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3 p-4">
{(jobs.data ?? []).map((job) => {
const done = job.success_count + job.partial_failed_count + job.failed_count;
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
return (
<div key={job.id} className="rounded-md border p-3">
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
<Progress className="my-2" value={progress} />
{job.items?.map((item) => (
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || item.phase}</span>
<span className="ml-auto">{item.progress}%</span>
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
</div>
))}
</div>
);
})}
{!jobs.data?.length && <p className="py-10 text-center text-sm text-muted-foreground">暂无归纳任务</p>}
</div>
</ScrollArea>
</div>
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
<div className="mb-2 text-muted-foreground">当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-32 truncate font-medium">{binding.skill_name}</span><span className="max-w-40 truncate text-muted-foreground">→ {binding.target_name}</span><Badge variant="outline">{binding.status}</Badge><code className="max-w-40 truncate text-[10px] text-muted-foreground">{binding.remote_business_key}</code><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解绑并保留</Button><Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>解绑并删除贡献</Button></div>)}</div>
</div>
</section>
</div>
);
}
@@ -3,18 +3,16 @@ import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
export function ThreadTitle({
className,
threadId,
thread,
}: {
export function ThreadTitle({
threadId,
thread,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
@@ -49,9 +47,9 @@ export function ThreadTitle({
if (!thread.values?.title) {
return null;
}
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className="min-w-0 w-full">
<span className="block truncate" title={visibleTitle}>
{visibleTitle}
</span>
@@ -1,6 +1,6 @@
"use client";
import { User } from "lucide-react";
import { UserRound } from "lucide-react";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useMyPosition } from "@/core/positions";
@@ -15,7 +15,13 @@ import { cn } from "@/lib/utils";
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
* 而非 Header 的白字贴 banner 方案。
*/
export function UserIdentityBadge({ className }: { className?: string }) {
export function UserIdentityBadge({
className,
variant = "plain",
}: {
className?: string;
variant?: "plain" | "topbar";
}) {
// userInfo belongs to external-app route parameters and can legitimately
// outlive a login switch. The visible account name must instead match the
// settings page's current auth identity.
@@ -26,13 +32,33 @@ export function UserIdentityBadge({ className }: { className?: string }) {
return (
<div
className={cn("flex shrink-0 items-center gap-2", className)}
data-workspace-user={variant}
className={cn(
"flex shrink-0 items-center gap-2",
variant === "topbar" &&
"h-10 max-w-[240px] rounded-lg px-1.5 py-1 text-slate-700 dark:text-slate-200",
className,
)}
title={positionName ? `${displayName}·${positionName}` : displayName}
>
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full">
<User className="size-3.5" />
<div
className={cn(
"bg-muted text-muted-foreground relative flex size-7 shrink-0 items-center justify-center rounded-full",
variant === "topbar" &&
"bg-primary text-primary-foreground size-8 shadow-[0_4px_12px_-6px_rgba(15,23,42,0.26)] ring-1 ring-primary/15",
)}
>
<UserRound className={cn("size-3.5", variant === "topbar" && "size-4")} />
{variant === "topbar" && (
<span className="absolute right-0 bottom-0 size-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-950" />
)}
</div>
<div className="hidden min-w-0 text-left sm:block">
<div
className={cn(
"hidden min-w-0 text-left sm:block",
variant === "topbar" && "max-w-[165px]",
)}
>
<p className="text-foreground truncate text-sm font-medium leading-tight">
{displayName}
</p>
@@ -12,8 +12,11 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
export function WorkspaceContainer({
className,
@@ -93,9 +96,19 @@ export function WorkspaceHeader({
</BreadcrumbList>
</Breadcrumb>
</div>
{rightActions ? (
<div className="flex items-center gap-2 px-4">{rightActions}</div>
) : null}
<div className="flex items-center gap-2 px-4">
{rightActions}
{!env.FORCE_COLLAPSED_SIDEBAR && (
<>
<ThemeSwitcher tone="content" />
<div
aria-hidden="true"
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
/>
</>
)}
<UserIdentityBadge variant="topbar" />
</div>
</header>
);
}
@@ -123,7 +136,8 @@ function nameOfSegment(
t: ReturnType<typeof useI18n>["t"],
) {
if (!segment) return t.common.home;
if (segment === "workspace") return t.breadcrumb.workspace;
if (segment === "chats") return t.breadcrumb.chats;
return segment[0]?.toUpperCase() + segment.slice(1);
}
if (segment === "workspace") return t.breadcrumb.workspace;
if (segment === "chats") return t.breadcrumb.chats;
if (segment === "agents") return t.agents.title;
return segment[0]?.toUpperCase() + segment.slice(1);
}
@@ -5,21 +5,26 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import cmAssistantLogoDark from "@/assets/img/cm-assistant-logo.png";
import cmAssistantLogoLight from "@/assets/img/cm-assistant-logo-red.png";
import { useTheme } from "@/contexts/ThemeContext";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
const { theme } = useTheme();
const brandLogoSrc =
theme === "dark"
? cmAssistantLogoDark
: cmAssistantLogoLight;
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
className,
)}
aria-hidden
>
C
</div>
<img
src={brandLogoSrc}
alt="CM 助手"
className={cn("size-9 shrink-0 object-contain", className)}
/>
);
}
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -65,13 +65,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "workspace-library",
label: "资料库",
icon: Library,
path: "/page/workspace/library",
match: (pathname) => pathname === "/page/workspace/library",
},
{
id: "workspace-agents",
label: "智能体",
@@ -254,14 +254,6 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills?tab=knowledge")}
>
<DatabaseZapIcon />
技能到知识库管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
@@ -1,140 +1,140 @@
"use client";
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa =
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
if (forceCollapsed) {
return (
<Sidebar
variant="sidebar"
collapsible="icon"
className={cn(
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
className,
)}
{...props}
>
<SidebarContent>
<WorkspaceNavChatList />
</SidebarContent>
</Sidebar>
);
}
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return (
<div
aria-hidden={!isSidebarOpen}
className={cn(
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
)}
>
<Sidebar
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
<SidebarContent>
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent>
<SidebarRail />
</Sidebar>
</div>
);
}
return (
<>
<Sidebar
variant="sidebar"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
全量模式本就不展示。 */}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
);
}
"use client";
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
if (forceCollapsed) {
return (
<Sidebar
variant="sidebar"
collapsible="icon"
className={cn(
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
className,
)}
{...props}
>
<SidebarContent>
<WorkspaceNavChatList />
</SidebarContent>
</Sidebar>
);
}
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return (
<div
aria-hidden={!isSidebarOpen}
className={cn(
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
)}
>
<Sidebar
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
<SidebarContent>
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent>
<SidebarRail />
</Sidebar>
</div>
);
}
return (
<>
<Sidebar
variant="sidebar"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
{isSidebarOpen && !compactChrome && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
);
}
+5 -24
View File
@@ -1,6 +1,5 @@
import { apiFetch } from "../api";
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
import { getFileName } from "../utils/files";
import { apiFetch } from "../api";
import { getFileName } from "../utils/files";
import { urlOfArtifact } from "./utils";
@@ -40,7 +39,7 @@ async function errorMessageFromResponse(res: Response): Promise<string> {
return res.statusText || "Request failed";
}
export async function downloadArtifactFile({
export async function downloadArtifactFile({
filepath,
threadId,
isMock,
@@ -74,26 +73,8 @@ export async function downloadArtifactFile({
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
export async function downloadArtifactMarkdownAsWord({
filepath,
threadId,
}: {
filepath: string;
threadId: string;
}): Promise<void> {
const url = urlOfArtifact({ filepath, threadId, download: false });
const res = await apiFetch(url);
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
const fileName = getFileName(filepath);
const title = fileName.replace(/\.(md|markdown)$/i, "");
await downloadMarkdownAsDocx(await res.text(), fileName, title);
}
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
export async function openArtifactInNewTab({
filepath,
-113
View File
@@ -1,113 +0,0 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type ArtifactLibraryKind =
| "all"
| "document"
| "spreadsheet"
| "text_data"
| "archive"
| "other";
export type ArtifactLibrarySource = "all" | "general_qa" | "agent";
export interface ArtifactLibraryItem {
threadId: string;
threadTitle: string;
agentId: string | null;
agentName: string | null;
threadType: string | null;
sourceType: Exclude<ArtifactLibrarySource, "all">;
path: string;
name: string;
sizeBytes: number;
mimeType: string | null;
modifiedAt: string;
fileKind: Exclude<ArtifactLibraryKind, "all">;
}
export interface ArtifactLibraryResponse {
total: number;
offset: number;
limit: number;
items: ArtifactLibraryItem[];
}
export async function fetchArtifactLibrary({
query,
fileKind,
sourceType,
offset,
limit,
refresh = false,
signal,
}: {
query?: string;
fileKind: ArtifactLibraryKind;
sourceType: ArtifactLibrarySource;
offset: number;
limit: number;
refresh?: boolean;
signal?: AbortSignal;
}): Promise<ArtifactLibraryResponse> {
const search = new URLSearchParams({
file_kind: fileKind,
source_type: sourceType,
offset: String(offset),
limit: String(limit),
});
const normalizedQuery = query?.trim();
if (normalizedQuery) search.set("query", normalizedQuery);
if (refresh) search.set("refresh", "true");
const response = await apiFetch(
`${getBackendBaseURL()}/api/artifact-library?${search.toString()}`,
{ method: "GET", signal },
);
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
const data = (await response.json()) as {
total?: number;
offset?: number;
limit?: number;
items?: Array<{
thread_id: string;
thread_title: string;
agent_id?: string | null;
agent_name?: string | null;
thread_type?: string | null;
source_type?: Exclude<ArtifactLibrarySource, "all">;
path: string;
name: string;
size_bytes: number;
mime_type?: string | null;
modified_at: string;
file_kind: Exclude<ArtifactLibraryKind, "all">;
}>;
};
return {
total: data.total ?? 0,
offset: data.offset ?? offset,
limit: data.limit ?? limit,
items: (data.items ?? []).map((item) => ({
threadId: item.thread_id,
threadTitle: item.thread_title,
agentId: item.agent_id ?? null,
agentName: item.agent_name ?? item.agent_id ?? null,
threadType: item.thread_type ?? null,
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
path: item.path,
name: item.name,
sizeBytes: item.size_bytes,
mimeType: item.mime_type ?? null,
modifiedAt: item.modified_at,
fileKind: item.file_kind,
})),
};
}
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
isAutoPreviewableArtifactPath,
isArtifactApiPath,
isProvisionalStreamingArtifactPath,
provisionalWriteFilePath,
@@ -31,10 +30,3 @@ test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => {
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true);
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true);
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false);
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false);
});
@@ -59,23 +59,6 @@ export function isArtifactApiPath(path: string): boolean {
);
}
/**
* Whether a streamed write is a user-facing deliverable that may open the
* artifact panel automatically. Agents often use ``workspace/`` for helper
* scripts and intermediate data; previewing those files is noisy and can show
* an empty sandbox before the real output exists.
*/
export function isAutoPreviewableArtifactPath(path: string): boolean {
if (isProvisionalStreamingArtifactPath(path)) {
return true;
}
const normalized = normalizeStreamingArtifactPath(path)
.split(/[?#]/, 1)[0]
?.replace(/^\/+/, "")
.replace(/\\/g, "/");
return normalized === "mnt/user-data/outputs" || normalized?.startsWith("mnt/user-data/outputs/");
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
@@ -1,274 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isWriteFileInFlight,
latestInFlightWriteFilePreview,
resolveWriteFilePath,
} from "./write-file-in-flight";
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 标题\n",
},
},
{ id: "p1", name: "present_files", args: { paths: ["/mnt/user-data/outputs/report.md"] } },
],
},
]);
assert.ok(preview);
assert.equal(preview?.previewKey, "m1:w1");
assert.match(preview?.artifactPath ?? "", /write-file:/);
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview keeps markdown when a later json write is in flight", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 标题\n",
},
},
{
id: "w2",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{",
},
},
],
},
]);
assert.ok(preview);
assert.equal(preview?.previewKey, "m1:w1");
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown in writing mode", () => {
const messages = [
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
content: "# 草稿\n",
},
},
],
},
];
assert.equal(latestInFlightWriteFilePreview(messages, true), null);
});
test("latestInFlightWriteFilePreview keeps custom outputs markdown live in normal mode", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
content: "# 草稿\n",
},
},
],
},
]);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /ai-agent-architecture-paper\.md/);
});
test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => {
const preview = latestInFlightWriteFilePreview(
[
{ type: "human", content: "写一篇论文" },
{
id: "m-pipe",
type: "ai",
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
},
{
id: "m-write",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 我自己写\n",
},
},
],
},
],
true,
);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview still opens the injected research stub", () => {
const preview = latestInFlightWriteFilePreview(
[
{
id: "research-report-j1",
type: "ai",
tool_calls: [
{
id: "research-report-j1-write",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告\n",
},
},
],
},
],
true,
);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("writing mode still previews follow-up-turn writes after a later user message", () => {
// 续写轮(成功之后用户又发了新消息):真实 write_file 编辑照常预览。
const preview = latestInFlightWriteFilePreview(
[
{ type: "human", content: "写一篇论文" },
{
id: "m-pipe",
type: "ai",
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
},
{
id: "m-result",
type: "tool",
tool_call_id: "p1",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md 并展示给用户。",
},
{ type: "human", content: "把第二章改写得更学术一些" },
{
id: "m-edit",
type: "ai",
tool_calls: [
{
id: "w2",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 修订后的报告\n",
},
},
],
},
],
true,
);
assert.ok(preview);
assert.equal(preview?.previewKey, "m-edit:w2");
});
test("normal mode keeps previewing pathless streaming drafts", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } },
],
},
]);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /\.md/);
});
test("latestInFlightWriteFilePreview ignores workspace helper files", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/workspace/scan_structure.py",
content: "print('scan')",
},
},
{
id: "w2",
name: "write_file",
args: {
path: "/mnt/user-data/workspace/draft.html",
content: "<!doctype html><html></html>",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{}]",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview skips write_file that already has a tool result", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } },
],
},
{ type: "tool", tool_call_id: "w1" },
]);
assert.equal(preview, null);
});
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal(
@@ -1,15 +1,5 @@
import { isInterceptedWritingReportWrite } from "../threads/writing-mode";
import { isImageFile } from "../utils/files";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
@@ -37,12 +27,8 @@ export function resolveWriteFilePath(filepath: string): string {
type WriteFileThreadLike = {
messages: Array<{
id?: string;
type?: string;
name?: string;
content?: unknown;
tool_call_id?: string;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{
id?: string;
name?: string;
@@ -89,70 +75,3 @@ export function isWriteFileInFlight(
}
return false;
}
/**
* Latest in-flight `write_file` that the sandbox can live-preview.
* Scans all messages (not just the last CoT step) so a write that is
* followed by `present_files` / bash in the same turn still opens the panel
* while arguments stream in — same idea as position-roundtable's
* `latestStreamingWriteFile`.
*/
export function latestInFlightWriteFilePreview(
messages: WriteFileThreadLike["messages"],
writingMode = false,
): { artifactPath: string; previewKey: string } | null {
const thread = { messages };
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex];
if (message?.type !== "ai" || !message.id || !message.tool_calls) {
continue;
}
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) {
continue;
}
if (hasToolResult(thread, toolCall.id)) {
continue;
}
const args = toolCall.args as { path?: unknown; content?: unknown } | undefined;
const rawPath = typeof args?.path === "string" ? args.path : "";
const content = typeof args?.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: content.trim()
? provisionalWriteFilePath(content)
: null;
if (!sourcePath) {
continue;
}
// Writing mode rewrites custom outputs/*.md to deep_research_report.
// Opening that filename would 404-poll until report.md lands. Normal
// mode never skips: the write is a real deliverable, not a rewrite.
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) {
continue;
}
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
// switching the sandbox onto it freezes the main thread, then the rest
// of the turn appears to dump at once. Keep showing markdown instead.
if (!languageOfStreamingArtifact(sourcePath)) {
continue;
}
if (!isAutoPreviewableArtifactPath(sourcePath)) {
continue;
}
return {
artifactPath: buildWriteFileVirtualUrl({
path: sourcePath,
messageId: message.id,
toolCallId: toolCall.id,
}),
previewKey: `${message.id}:${toolCall.id}`,
};
}
}
return null;
}
+18 -2
View File
@@ -10,6 +10,7 @@
*/
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
import type { ThemeType } from "@/contexts/ThemeContext";
export const EXTERNAL_LOGIN_STORAGE_KEYS = {
ticket: "login.ticket",
@@ -58,6 +59,21 @@ export function readExternalLoginParams(): Required<ExternalLoginParams> {
* - ``blue`` → 暂无该主题,先用浅色代替(命中 else 分支)
* 其余/缺省同样回退浅色。将来若新增 blue 主题,在此扩展映射即可。
*/
export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" {
return themeParam === "dark-blue" ? "dark" : "light";
export function resolveThemeFromQuery(themeParam: string | null | undefined): ThemeType {
switch (themeParam?.trim().toLowerCase()) {
case "dark":
case "dark-blue":
case "darkblue":
return "dark";
case "blue":
case "lightblue":
case "light-blue":
return "lightblue";
case "green":
case "lightgreen":
case "light-green":
return "lightgreen";
default:
return "light";
}
}
+11 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import type { ThemeType } from "@/contexts/ThemeContext";
/**
* 公开页(被第三方 iframe 嵌套)的可配置外观:背景色 + 整体明暗主题。
@@ -63,19 +64,25 @@ function readFromSearch(search: string): PublicBackground {
*/
export function normalizePublicTheme(
raw: string | null | undefined,
): "dark" | "light" | undefined {
): ThemeType | undefined {
const value = raw?.trim().toLowerCase();
if (!value) return undefined;
if (value === "dark" || value === "dark-blue" || value === "深蓝" || value === "深色") {
return "dark";
}
if (value === "blue" || value === "lightblue" || value === "light-blue" || value === "浅蓝") {
return "lightblue";
}
if (value === "green" || value === "lightgreen" || value === "light-green" || value === "绿色") {
return "lightgreen";
}
if (value === "light" || value === "red" || value === "红白" || value === "浅色") {
return "light";
}
return undefined;
}
function readThemeFromSearch(search: string): "dark" | "light" | undefined {
function readThemeFromSearch(search: string): ThemeType | undefined {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
return normalizePublicTheme(params.get("theme"));
}
@@ -86,7 +93,7 @@ function readThemeFromSearch(search: string): "dark" | "light" | undefined {
* 与 `usePublicBackground` 同样合并两处查询串(hash 路由内优先),
* postMessage 的动态值优先级最高。返回 undefined 表示外部未指定。
*/
export function usePublicEmbedTheme(): "dark" | "light" | undefined {
export function usePublicEmbedTheme(): ThemeType | undefined {
const location = useLocation();
const fromUrl = useMemo(() => {
@@ -97,7 +104,7 @@ export function usePublicEmbedTheme(): "dark" | "light" | undefined {
return inner ?? outer;
}, [location.search]);
const [override, setOverride] = useState<"dark" | "light" | undefined>(undefined);
const [override, setOverride] = useState<ThemeType | undefined>(undefined);
useEffect(() => {
function onMessage(event: MessageEvent) {
@@ -1,5 +1,6 @@
/**
* embed-session.ts 浏览上下文隔离单测。
* 运行方式(项目无 vitest):
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
* node --test .tmp-tests/embed-session.test.mjs
@@ -81,11 +82,14 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
asTop();
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
asIframe();
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
assert.equal(isEmbedActive(), true);
assert.equal(isChromeHidden(), true);
assert.ok(storage.getItem("embed.session.nested"));
assert.equal(storage.getItem("embed.session"), null);
asTop();
assert.equal(isEmbedActive(), false);
@@ -93,9 +97,11 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
});
});
test("top ignores old nested embed.session without clearing it", () => {
test("top parent reclaims pollution from an old nested iframe (no topOwned)", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
syncEmbedSessionFromSearch("");
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
storage.setItem(
"embed.session",
JSON.stringify({
@@ -107,14 +113,15 @@ test("top ignores old nested embed.session without clearing it", () => {
isolate: false,
}),
);
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
// 内层老版仍要读到 embed=true
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
assert.equal(storage.getItem("embed.session"), null);
});
});
test("top sync does not wipe unowned embed.session", () => {
test("top sync after old nested pollution does not mark owner", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
@@ -130,11 +137,12 @@ test("top sync does not wipe unowned embed.session", () => {
);
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
test("nested iframe leaves the shared embed.session for old inner pages", () => {
test("nested iframe reclaims a polluted shared embed.session key", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
@@ -151,11 +159,11 @@ test("nested iframe leaves the shared embed.session for old inner pages", () =>
asIframe();
syncEmbedSessionFromSearch("?embed=1");
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
assert.ok(storage.getItem("embed.session.nested"));
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
@@ -176,7 +184,31 @@ test("nested iframe keeps the top window's own embed session", () => {
});
});
test("top ignore strips chrome in memory but keeps isolate", () => {
test("nested reclaim strips embed but keeps top isolate", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: true,
}),
);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
});
});
test("top reclaim strips embed but keeps isolate without nested help", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
@@ -192,6 +224,5 @@ test("top ignore strips chrome in memory but keeps isolate", () => {
);
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
});
});
+199 -98
View File
@@ -16,17 +16,34 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
* 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate`
* 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。
*
* 同源陷阱:sessionStorage 按「顶层标签页 × origin」分区,同一标签里所有同源文档共用一份。
* 全量父页套外部系统、外部再套咱们老版 `embed=1` 深链时,内层会写共用的 `embed.session`。
* **禁止**在顶层父页清掉这份键——内层老版还靠它藏导航栏。顶层只在「本窗自己从 URL 认领」时
* 才把 embed 当成自己的;否则读取时忽略 chrome 标志、且不写回存储。
* 嵌套新版写 `embed.session.nested`,与顶层键分开。深链问答/会商/大屏绘制另外按路由去 chrome
* (见 `isHostNestedSurfacePath`),不依赖这份会话。
* 三者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏;
* `isolate` 只切换鉴权存储,不影响 chrome / 主题 / 跳转栏。
*
* 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响):
* - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage
* (跨标签共享 → 会污染正常页)。
* - 主题走 next-themes 的 `forcedTheme`(见 App.tsx `AppProviders`)——只在本标签页生效、
* 不写 localStorage `strategy-theme`;嵌入侧绝不调 `setTheme`。
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
* localStorage 偏好。
*
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
* 路由看起来却没变。因此:
* - 顶层窗口写 `embed.session`,嵌套 iframe(新版)写 `embed.session.nested`;
* - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时,
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
*/
const EMBED_SESSION_KEY = "embed.session";
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
/**
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
* 嵌套同源 iframe 看到此标记就不会清掉顶层合法的 embed/hideChrome 持久化。
*/
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
@@ -44,6 +61,7 @@ function getSessionStorage(): Storage | null {
}
}
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
function isTopLevelWindow(): boolean {
const w = getWindow();
if (!w) return true;
@@ -60,14 +78,28 @@ function embedSessionKey(): string {
export type EmbedTheme = "light" | "dark";
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
export interface EmbedSession {
/** 嵌入模式:去全局 chrome + 强制主题。 */
embed: boolean;
/** 嵌入模式下强制的主题(仅 embed 为真时有意义)。 */
theme: EmbedTheme;
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
hideJump: boolean;
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
hideHeader: boolean;
/**
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
* 一个干净的全屏页面(如「大屏绘制」`?hideChrome=1`),其余行为与正常标签页完全一致。
*/
hideChrome: boolean;
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
isolate: boolean;
/** 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省表示可能是同源嵌套页写的共用键。 */
/**
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
*/
topOwned?: boolean;
}
@@ -110,12 +142,49 @@ function emptySession(): EmbedSession {
};
}
function stripChromeFlags(session: EmbedSession): EmbedSession {
return {
...session,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
}
function persistTopSession(next: EmbedSession): void {
const store = getSessionStorage();
if (!store) return;
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(EMBED_SESSION_KEY);
return;
}
const payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
hideHeader: next.hideHeader,
hideChrome: next.hideChrome,
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
store.setItem(EMBED_SESSION_KEY, JSON.stringify(payload));
}
function isTopChromeOwned(session?: EmbedSession | null): boolean {
const w = getWindow();
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
return session?.topOwned === true;
}
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
function markTopChromeOwner(session: EmbedSession): void {
const w = getWindow();
if (!w || !isTopLevelWindow()) return;
@@ -123,25 +192,31 @@ function markTopChromeOwner(session: EmbedSession): void {
w[TOP_CHROME_OWNER_FLAG] = true;
}
function readStoredSession(): EmbedSession | null {
/**
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
*/
function reclaimUnownedTopChrome(): void {
if (!isTopLevelWindow()) return;
try {
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur || !sessionHidesChrome(cur)) return;
if (isTopChromeOwned(cur)) {
const w = getWindow();
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
return;
}
persistTopSession(stripChromeFlags(cur));
} catch {
return null;
}
}
function readSharedTopSession(): EmbedSession | null {
try {
return parseEmbedSession(getSessionStorage()?.getItem(EMBED_SESSION_KEY) ?? null);
} catch {
return null;
/* noop */
}
}
/**
* 旧版嵌套页写过共用键时,新版嵌套页复制一份到 nested 键自己用;**不删除**共用键,
* 避免老版内层丢掉 embed 后把导航栏放出来。
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
*/
function migrateLegacySharedSessionIfNested(): void {
if (isTopLevelWindow()) return;
@@ -155,12 +230,30 @@ function migrateLegacySharedSessionIfNested(): void {
}
}
function reclaimPollutedTopChrome(): void {
if (isTopLevelWindow()) return;
try {
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur) return;
// 顶层已认领(内存标记或 session.topOwned)则不动。
if (top?.[TOP_CHROME_OWNER_FLAG] === true || cur.topOwned === true) return;
persistTopSession(stripChromeFlags(cur));
} catch {
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
}
}
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
type FlagParam = "on" | "off" | null;
function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
}
/** 读某个布尔型 query 参数的三态。`1/true/yes` → on;`0/false/no` → off;缺省/其它 → null。 */
function readFlagParam(search: string, key: string): FlagParam {
const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase();
if (!v) return null;
@@ -169,26 +262,32 @@ function readFlagParam(search: string, key: string): FlagParam {
return null;
}
/** 读 URL 里的 `embed` 三态。 */
export function readEmbedParam(search: string): FlagParam {
return readFlagParam(search, "embed");
}
/** 读 URL 里的 `hideJump` 三态。 */
export function readHideJumpParam(search: string): FlagParam {
return readFlagParam(search, "hideJump");
}
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
export function readHideHeaderParam(search: string): FlagParam {
return readFlagParam(search, "hideHeader");
}
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
export function readHideChromeParam(search: string): FlagParam {
return readFlagParam(search, "hideChrome");
}
/** 读 URL 里的 `isolate` 三态(独立会话登录)。 */
export function readIsolateParam(search: string): FlagParam {
return readFlagParam(search, "isolate");
}
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
export function readEmbedThemeParam(search: string): string {
return (toSearchParams(search).get("theme") ?? "").trim();
}
@@ -202,32 +301,34 @@ function searchEnablesChromeHide(search: string): boolean {
);
}
/**
* 读本浏览上下文的嵌入会话。顶层若看到无主(非 topOwned)的 chrome 标志,视为同源
* 嵌套页写的,**读取时忽略、不写回**,以免清掉内层老版仍在用的 `embed.session`。
*/
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
export function readEmbedSession(): EmbedSession | null {
const s = readStoredSession();
if (!s) return null;
if (isTopLevelWindow() && sessionHidesChrome(s) && !isTopChromeOwned(s)) {
return {
...s,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
try {
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。
reclaimUnownedTopChrome();
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
} catch {
return null;
}
return s;
}
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
const store = getSessionStorage();
if (!store) return;
const key = embedSessionKey();
let payload: EmbedSession = {
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(key);
return;
}
const payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
@@ -236,56 +337,37 @@ function writeEmbedSession(next: EmbedSession): void {
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
// 顶层未认领时,写 isolate 等字段不得覆盖内层老版仍占用的 chrome 标志。
if (
isTopLevelWindow() &&
key === EMBED_SESSION_KEY &&
!payload.topOwned &&
getWindow()?.[TOP_CHROME_OWNER_FLAG] !== true
) {
const shared = readSharedTopSession();
if (shared && sessionHidesChrome(shared) && !shared.topOwned) {
payload = {
...payload,
embed: shared.embed,
theme: shared.theme,
hideJump: shared.hideJump,
hideHeader: shared.hideHeader,
hideChrome: shared.hideChrome,
};
}
}
if (
!payload.embed &&
!payload.hideJump &&
!payload.hideHeader &&
!payload.hideChrome &&
!payload.isolate
) {
store.removeItem(key);
return;
}
store.setItem(key, JSON.stringify(payload));
} catch {
/* noop */
}
}
/** 本标签页是否处于嵌入模式(驱动去全局 chrome + 强制主题)。 */
export function isEmbedActive(): boolean {
return readEmbedSession()?.embed === true;
}
/** 本标签页是否要隐藏深链底部「快捷跳转」栏(TaskJumpBar)。 */
export function isJumpBarHidden(): boolean {
return readEmbedSession()?.hideJump === true;
}
/**
* Whether this tab should hide its global Header.
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
* deliberately leaves PageSidebar visible.
*/
export function isHeaderHidden(): boolean {
const session = readEmbedSession();
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
}
/**
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
* 与 `isEmbedActive()` 的差别:`hideChrome` 不强制主题、不隔离鉴权,只去 chrome。
*/
export function isChromeHidden(): boolean {
const s = readEmbedSession();
return s?.embed === true || s?.hideChrome === true;
@@ -295,38 +377,28 @@ export function isHideChromeActive(): boolean {
return readEmbedSession()?.hideChrome === true;
}
/**
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
* 供需要区分「纯独立会话(带 chrome)」与「嵌入态(去 chrome)」的场合使用。
*/
export function isIsolatedSession(): boolean {
return readEmbedSession()?.isolate === true;
}
/**
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
* 之前置位的开关不会被抹掉。
*
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
*/
export function syncEmbedSessionFromSearch(search: string): void {
if (!isTopLevelWindow()) {
migrateLegacySharedSessionIfNested();
applySearchToSession(search, readStoredSession() ?? emptySession(), false);
return;
}
migrateLegacySharedSessionIfNested();
if (isTopLevelWindow()) reclaimUnownedTopChrome();
const stored = readStoredSession() ?? emptySession();
if (searchEnablesChromeHide(search)) {
applySearchToSession(search, stored, true);
return;
}
if (isTopChromeOwned(stored)) {
applySearchToSession(search, stored, true);
return;
}
// 无主共用键:只处理 isolate URL,绝不把 chrome 标志写成 false 覆盖内层。
const isolateParam = readIsolateParam(search);
if (isolateParam === null) return;
const isolate = isolateParam === "on";
writeEmbedSession({ ...stored, isolate, topOwned: false });
}
function applySearchToSession(
search: string,
cur: EmbedSession,
topOwned: boolean,
): void {
const cur = readEmbedSession() ?? emptySession();
const next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search);
@@ -353,11 +425,27 @@ function applySearchToSession(
if (isolateParam === "on") next.isolate = true;
else if (isolateParam === "off") next.isolate = false;
next.topOwned = topOwned;
if (isTopLevelWindow()) {
if (searchEnablesChromeHide(search)) {
next.topOwned = true;
} else if (!isTopChromeOwned(next)) {
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
} else {
next.topOwned = true;
}
}
writeEmbedSession(next);
if (topOwned) markTopChromeOwner(next);
if (isTopLevelWindow()) markTopChromeOwner(next);
else reclaimPollutedTopChrome();
}
/**
* 从当前 HashRouter URL(`#/path?a=b`)取出 query 串。
* 供「在 React 渲染之前」同步启用嵌入会话(main.tsx)——必须早于任何鉴权写入,
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
*/
function currentHashSearch(): string {
const w = getWindow();
if (!w) return "";
@@ -366,10 +454,18 @@ function currentHashSearch(): string {
return i >= 0 ? hash.slice(i) : "";
}
/** main.tsx 在渲染前同步调用一次:据当前 URL 启用/关闭本标签页嵌入会话。 */
export function syncEmbedSessionFromCurrentUrl(): void {
syncEmbedSessionFromSearch(currentHashSearch());
}
/**
* 解析「应当强制的嵌入主题」供 `AppProviders` 的 `forcedTheme` 用:
* - URL 显式 `embed=1` → 用 URL `theme`(首帧即生效,登录页转场不闪烁);
* - URL 显式 `embed=0` → 不强制(undefined);
* - URL 未带 → 回退本标签页嵌入会话主题(仅 embed 置位时)。
* 非嵌入标签页恒返回 undefined → 完全不影响正常系统页的主题。
*/
export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined {
const param = readEmbedParam(search);
if (param === "on") return resolveEmbedThemeMode(readEmbedThemeParam(search));
@@ -378,7 +474,12 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
return session?.embed ? session.theme : undefined;
}
/**
* 运行时直接置位/清除本标签页的独立会话标志(供「外观」设置里的开关用,区别于 URL 参数驱动)。
* 仅切换标志;登录态的「localStorage 快照搬进 sessionStorage」由 [[scoped-storage]]
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
*/
export function setIsolatedSession(on: boolean): void {
const stored = readStoredSession() ?? emptySession();
writeEmbedSession({ ...stored, isolate: on });
const cur = readEmbedSession() ?? emptySession();
writeEmbedSession({ ...cur, isolate: on });
}
+5 -2
View File
@@ -1,4 +1,5 @@
import { resolveThemeFromQuery } from "@/core/auth/external-login";
import type { ThemeType } from "@/contexts/ThemeContext";
const DEFAULT_USERNAME = "guest";
@@ -41,7 +42,7 @@ export function resolveEmbedChatParams(search: string): EmbedChatParams {
return { sessionId, message, username, theme };
}
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
export function resolveEmbedTheme(themeParam: string): ThemeType {
return resolveThemeFromQuery(themeParam || null);
}
@@ -50,8 +51,10 @@ export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
*/
export function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
export function resolveEmbedRoundtableTheme(raw: string): ThemeType {
const v = raw.trim().toLowerCase();
if (v === "light" || v === "red" || v === "红白") return "light";
if (v === "blue" || v === "lightblue" || v === "light-blue" || v === "浅蓝") return "lightblue";
if (v === "green" || v === "lightgreen" || v === "light-green" || v === "绿色") return "lightgreen";
return "dark";
}
@@ -149,7 +149,7 @@ export function buildWikiIndexCatalogMarkdown(
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
indexGroups: WikiIndexGroup[] = [],
): string {
if (indexGroups.some((group) => group.items.length > 0)) {
if (indexGroups.length > 0) {
const sections: string[] = [];
for (const group of indexGroups) {
if (group.items.length === 0) continue;
@@ -56,61 +56,6 @@ test("write_file tool after 用户协助 opens a processing group instead of err
]);
});
test("writing-mode progress merges into the deep_research_report bar so the summary sits below", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "写一篇论文" } as Message,
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: { query: "多智能体" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "deep_research_report",
content: "ok",
tool_call_id: "c1",
} as Message,
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "complete" } },
],
} as Message,
{
type: "ai",
id: "write",
content: "",
tool_calls: [{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } }],
} as Message,
{
type: "ai",
id: "summary",
content: "已生成多智能体论文,详见 report.md。",
} as Message,
]);
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
});
test("a lone deep-research progress bar still opens its own group", () => {
const types = groupTypes([
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "active" } },
],
} as Message,
]);
assert.deepEqual(types, ["assistant:processing"]);
});
test("tool messages still attach to an open processing group", () => {
const types = groupTypes([
{
+8 -21
View File
@@ -166,19 +166,14 @@ export function groupMessages<T>(
messages: [message],
});
} else if (hasOnlyDeepResearchProgress(message)) {
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
// 会有 human/assistant 隔开,不会误并到上一轮的条上。
const lastGroup = groups[groups.length - 1];
if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
lastGroup.messages.push(message);
} else {
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
}
// Each deep-research writing bar is its own group. Merging consecutive
// processing messages would pin a new report's in-progress steps onto
// the parent bar, which sits above the original file card.
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
} else if (hasSubagent(message)) {
groups.push({
id: message.id,
@@ -431,14 +426,6 @@ export function hasOnlyDeepResearchProgress(message: Message) {
);
}
function groupHasDeepResearchReport(group: MessageGroup): boolean {
return group.messages.some(
(message) =>
message.type === "ai" &&
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")),
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
@@ -1,70 +0,0 @@
/**
* host-nested-surface 深链嵌套页路径判定。
* npx esbuild src/core/page-layout/host-nested-surface.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/host-nested-surface.test.mjs
* node --test .tmp-tests/host-nested-surface.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import { isHostNestedSurfacePath } from "./host-nested-surface";
test("matches deeplink single-agent workspaces", () => {
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task-sentiment/agent/abc/chats",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-fx/agent/abc/chats/thread-1",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-action/agent/abc/chats/new",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task-agentfx/agent/agentfx-analyst/chats",
),
true,
);
});
test("matches deeplink roundtable and dashboard-agent", () => {
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-fx/roundtable",
),
true,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/dashboard-agent"),
true,
);
});
test("does not match full-mode pages we use ourselves", () => {
assert.equal(
isHostNestedSurfacePath("/page/strategy/task-management/xk-task-jc"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/task-management/plan-task-sentiment"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/qa/multi-agent-roundtable"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/workspace/roundtable/planning"),
false,
);
assert.equal(isHostNestedSurfacePath("/page/workspace/chats/new"), false);
});
@@ -1,21 +0,0 @@
/**
* 外部系统 iframe 深链套用的页面:单智能体问答、多智能体会商、大屏绘制。
* 这些路由只给宿主嵌套,不走全量 Header / PageSidebar;chrome 由路径决定,
* 不依赖(也不改写)同源 iframe 共用的 `embed.session`。
*/
/** 任务管理下的深链会商(goPath=`*-A`)。 */
const TASK_ROUNDTABLE_RE =
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action)\/roundtable(?:\/|$)/;
/** 任务管理下的深链单智能体问答(含 agentfx)。 */
const TASK_AGENT_CHATS_RE =
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action|plan-task-agentfx)\/agent\/[^/]+\/chats/;
export function isHostNestedSurfacePath(pathname: string): boolean {
const path = (pathname || "").split("?")[0] || "";
if (path.includes("/strategy/dashboard-agent")) return true;
if (TASK_ROUNDTABLE_RE.test(path)) return true;
if (TASK_AGENT_CHATS_RE.test(path)) return true;
return false;
}
@@ -61,7 +61,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{
id: "multi-agent-roundtable",
label: "多智能体会商",
@@ -74,12 +73,12 @@ export const sidebarMenuItems: MenuItem[] = [
// icon: Briefcase,
// path: "/page/strategy/qa/position-roundtable",
// },
// {
// id: "dashboard-agent",
// label: "大屏绘制",
// icon: Gauge,
// path: "/page/strategy/dashboard-agent",
// },
{
id: "dashboard-agent",
label: "大屏绘制",
icon: Gauge,
path: "/page/strategy/dashboard-agent",
},
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
@@ -89,12 +88,12 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
// {
// id: "qa-research-chat",
// label: "课题研究(对话版)",
// icon: FileText,
// path: "/page/canvas/ai-writing-chat",
// },
{
id: "qa-research-chat",
label: "课题研究(对话版)",
icon: FileText,
path: "/page/canvas/ai-writing-chat",
},
{
id: "qa-deep-research",
label: "深度研究",
@@ -9,31 +9,6 @@ export interface PromptPrefixSettings {
ordinary_qa_markdown_format_enabled: boolean;
}
export type KnowledgeBaseProductVersion = "v1" | "v2";
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
);
if (!response.ok) throw new Error(`Failed to load knowledge base product version: ${response.status}`);
return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version;
}
export async function updateKnowledgeBaseProductVersion(
version: KnowledgeBaseProductVersion,
): Promise<KnowledgeBaseProductVersion> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version }),
},
);
if (!response.ok) throw new Error(`Failed to update knowledge base product version: ${response.status}`);
return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version;
}
export interface PluginDomainAgentMappingDto {
id: string;
domain: string;
+3 -9
View File
@@ -1512,16 +1512,10 @@ export function useThreadStream({
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids,
)
? mergedContext.assistant_knowledge_base_ids
: [];
const ragModeEnabled =
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0;
selectedLlmWikiIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
@@ -1699,7 +1693,7 @@ export function useThreadStream({
? undefined
: thread.error;
const mergedThread = {
const mergedThread = {
...thread,
error: visibleThreadError,
values: mergedValues,
@@ -1709,7 +1703,7 @@ export function useThreadStream({
: mergedMessages,
isLoading: thread.isLoading || streamHoldOpen,
stop: handleThreadStop,
} as typeof thread;
} as typeof thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
-2
View File
@@ -27,8 +27,6 @@ export interface AgentThreadContext extends Record<string, unknown> {
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
@@ -6,7 +6,6 @@ import {
buildResearchFeedMessages,
buildResearchProgressToolCalls,
buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
clearResearchReportStream,
finalizeResearchReportStream,
ingestResearchReportEvent,
@@ -140,33 +139,15 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
sourceCount: 3,
});
const calls = buildResearchProgressToolCalls(active);
assert.equal(calls.length, 2); // 梳理思路已完成 + 整理资料进行中
assert.equal(calls.length, 2); // 拆解已完成 + 检索进行中
assert.equal(calls[0].args.status, "complete");
assert.equal(calls[0].args.label, "梳理写作思路");
assert.equal(calls[1].args.status, "active");
assert.equal(calls[1].args.label, "整理对话资料");
assert.match(calls[1].args.detail, /1\/2/);
const writing = streamOf({ jobId: "j7", phase: "writing", reportText: "# x" });
assert.equal(buildResearchProgressToolCalls(writing).length, 3);
assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active");
const completedByPhase = streamOf({ jobId: "j7b", phase: "done", reportText: "# x" });
const completedCalls = buildResearchProgressToolCalls(completedByPhase);
assert.equal(completedCalls.length, 3);
assert.ok(completedCalls.every((call) => call.args.status === "complete"));
assert.match(completedCalls[2].args.detail, /report\.md/);
const completedByStatus = streamOf({
jobId: "j7c",
phase: "writing",
status: "success",
reportText: "# x",
});
assert.ok(
buildResearchProgressToolCalls(completedByStatus).every((call) => call.args.status === "complete"),
);
const failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" });
const failedCalls = buildResearchProgressToolCalls(failed);
assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/);
@@ -176,73 +157,15 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
test("feed messages attach the write_file stub only once report text exists", () => {
const noText = streamOf({ jobId: "j9", phase: "collecting" });
assert.equal(buildResearchFeedMessages(noText).length, 1);
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls?.[0]?.name, "deep_research_progress");
const writingEmpty = streamOf({ jobId: "j9b", phase: "writing" });
assert.equal(buildResearchFeedMessages(writingEmpty).length, 1);
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls[0].name, "deep_research_progress");
const withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" });
const feed = buildResearchFeedMessages(withText);
assert.equal(feed.length, 2);
const stub = feed[1].tool_calls?.[0];
assert.equal(stub?.name, "write_file");
assert.equal(stub?.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub?.args.content, "# 报告正文");
const done = streamOf({ jobId: "j10b", phase: "done", reportText: "# 报告正文" });
const doneFeed = buildResearchFeedMessages(done);
assert.equal(doneFeed.length, 3);
assert.equal(doneFeed[2].type, "tool");
assert.equal(doneFeed[2].tool_call_id, "research-report-j10b-write");
});
test("report_completed flips phase to done and write step completes before tool_finished", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "jdone", payload: { delta: "正文", offset: 0 } },
{ type: "report_completed", jobId: "jdone", payload: { sourceCount: 2 } },
]);
const state = peekResearchReportStream(tid);
assert.equal(state?.phase, "done");
assert.equal(state?.status, "active");
const calls = buildResearchProgressToolCalls(state!);
assert.ok(calls.every((call) => call.args.status === "complete"));
});
test("composeResearchDisplayMessages inserts the bar before the summary and drops leaked prose", () => {
const feed = buildResearchFeedMessages(streamOf({
jobId: "jsplice",
phase: "done",
reportText: "# 报告",
}));
const composed = composeResearchDisplayMessages(
[
{ id: "h", type: "human", content: "写论文" },
{
id: "ai-tool",
type: "ai",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: {} }],
},
{ id: "leak", type: "ai", content: "# 报告\n\n泄漏到消息列表的正文" },
{ id: "tool", type: "tool", tool_call_id: "c1", content: "ok" },
{ id: "summary", type: "ai", content: "已生成论文,详见 report.md。" },
],
feed,
);
assert.deepEqual(
composed.map((message) => message.id),
[
"h",
"ai-tool",
"tool",
"research-progress-jsplice",
"research-report-jsplice",
"research-report-jsplice-write-result",
"summary",
],
);
const stub = feed[1].tool_calls[0];
assert.equal(stub.name, "write_file");
assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub.args.content, "# 报告正文");
});
test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => {
@@ -84,8 +84,8 @@ function getStore(threadId: string): StoreEntry {
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
* 10 次通知,流式观感不受影响。终态帧(tool_finished /
* report_completed)与 finalize/clear 立即 flush,结束态不滞后。
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与
* finalize/clear 立即 flush,结束态不滞后。
*/
const EMIT_COALESCE_MS = 100;
@@ -193,8 +193,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
case "report_completed":
next = {
...base,
// 文件已落盘:步骤条应立刻标完成,不必等随后的 tool_finished。
phase: "done",
phase: "writing",
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
? payload.sourceCount
: base.sourceCount,
@@ -224,7 +223,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
if (next !== previous) {
store.state = next;
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
if (frame.type === "tool_finished" || frame.type === "report_completed") {
if (frame.type === "tool_finished") {
emitNow(store);
} else {
emitCoalesced(store);
@@ -281,41 +280,33 @@ export type ResearchProgressToolCall = {
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
const rank = phaseRank(state.phase);
const finished = state.status !== "active";
// success 时必须用 length(越过 write 的 boundary),length-1 永远完不成最后一步。
// report_completed 会先把 phase 置 done,此时工具可能还没返回,也视为写完。
const succeeded =
state.status === "success" || (state.status === "active" && state.phase === "done");
const effectiveRank = succeeded ? PHASE_ORDER.length : rank;
const effectiveRank = finished && state.status === "success" ? PHASE_ORDER.length - 1 : rank;
const stages = [
{
id: "plan",
// 写作管线不检索:只按课题拆论述角度、汇总对话已收集的资料再成稿。
// 措辞避免「拆解检索问题/检索参考资料」,用户会误会管线在重新检索。
label: "梳理写作思路",
label: "拆解检索问题",
boundary: phaseRank("collecting"),
detail: state.queries.length > 0
? `已梳理出 ${state.queries.length} 个论述角度`
: "正在围绕课题梳理写作思路",
? `已拆出 ${state.queries.length} 个检索子问题`
: "正在围绕课题拆解检索子问题",
},
{
id: "collect",
label: "整理对话资料",
label: "检索参考资料",
boundary: phaseRank("compressing"),
detail: state.queries.length > 0
? `已整理 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个论述角度的资料 · 累计 ${state.sourceCount} 条`
? `已检索 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个子问题 · 累计 ${state.sourceCount} 条资料`
: state.sourceCount > 0
? `已汇总 ${state.sourceCount} 条对话资料`
: "正在汇总对话中已收集的资料",
? `已检索到 ${state.sourceCount} 条资料`
: "正在逐个子问题检索资料",
},
{
id: "write",
label: "撰写研究报告",
boundary: PHASE_ORDER.length,
detail: succeeded
? "已按「通用分析结构」写完并保存为 report.md"
: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
detail: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
},
];
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
@@ -339,25 +330,19 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
export type ResearchFeedMessage = {
id: string;
type: "ai" | "tool";
type: "ai";
content: string;
name?: string;
tool_call_id?: string;
tool_calls?: {
tool_calls: {
id: string;
name: string;
args: Record<string, unknown>;
}[];
};
function isResearchWriteFinished(state: ResearchReportStreamState): boolean {
return state.status === "success" || state.phase === "done";
}
/**
* 组装聊天页要注入的虚拟消息:进度步骤条 + report.md 的 write_file stub
* (沙箱 write-file 虚拟 URL 预览的数据源)。写完后补一条 tool result,
* 让沙箱的「正在生成」立刻收起,不必等真实 artifacts 交接。
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在
* 流式时输出 stub —— 完成后由真实 artifacts 接管。
*/
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
const messages: ResearchFeedMessage[] = [
@@ -368,127 +353,23 @@ export function buildResearchFeedMessages(state: ResearchReportStreamState): Res
tool_calls: buildResearchProgressToolCalls(state),
},
];
// 必须等正文首 token:思考阶段挂 stub 会提前显示「写入文件 report.md」。
if (!state.reportText.trim()) {
return messages;
}
const writeCallId = `research-report-${state.jobId}-write`;
messages.push({
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: writeCallId,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
});
if (isResearchWriteFinished(state)) {
if (state.reportText.trim()) {
messages.push({
id: `research-report-${state.jobId}-write-result`,
type: "tool",
name: "write_file",
tool_call_id: writeCallId,
content: "ok",
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: `research-report-${state.jobId}-write`,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
});
}
return messages;
}
function isDeepResearchReportAi(message: { type?: string; tool_calls?: Array<{ name?: string; id?: string }> }): boolean {
return (
message.type === "ai" &&
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report"))
);
}
function deepResearchReportCallIds(message: { tool_calls?: Array<{ name?: string; id?: string }> }): Set<string> {
return new Set(
(message.tool_calls ?? [])
.filter((toolCall) => toolCall.name === "deep_research_report" && toolCall.id)
.map((toolCall) => toolCall.id as string),
);
}
function messageHasVisibleText(message: { type?: string; content?: unknown; tool_calls?: unknown[] }): boolean {
if (message.type !== "ai") return false;
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return false;
return typeof message.content === "string" && message.content.trim().length > 0;
}
/**
* 丢掉管线内部 LLM 泄漏到父图 messages 流的正文:它们夹在
* `deep_research_report` 调用与其 tool result 之间,会让左侧气泡和沙箱一起写。
*/
export function omitLeakedReportTokens<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
): T[] {
const hidden = new Set<number>();
let pendingIds: Set<string> | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (isDeepResearchReportAi(message)) {
pendingIds = deepResearchReportCallIds(message);
continue;
}
if (!pendingIds) continue;
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
pendingIds.has(message.tool_call_id)
) {
pendingIds = null;
continue;
}
if (messageHasVisibleText(message)) {
hidden.add(i);
}
}
return hidden.size === 0 ? messages : messages.filter((_, index) => !hidden.has(index));
}
/**
* 把进度条 / write_file stub 插到 `deep_research_report` 及其结果之后、
* 模型总结之前,这样总结气泡永远在步骤条下面。
*/
export function insertResearchFeedMessages<T>(messages: T[], feed: T[]): T[] {
if (feed.length === 0) return messages;
let insertAt = -1;
let callIds = new Set<string>();
for (let i = 0; i < messages.length; i++) {
const message = messages[i] as { type?: string; tool_calls?: Array<{ name?: string; id?: string }> };
if (isDeepResearchReportAi(message)) {
insertAt = i + 1;
callIds = deepResearchReportCallIds(message);
}
}
if (insertAt === -1) {
return [...messages, ...feed];
}
while (insertAt < messages.length) {
const message = messages[insertAt] as { type?: string; tool_call_id?: string };
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
callIds.has(message.tool_call_id)
) {
insertAt += 1;
continue;
}
break;
}
return [...messages.slice(0, insertAt), ...feed, ...messages.slice(insertAt)];
}
export function composeResearchDisplayMessages<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
feed: T[],
): T[] {
return insertResearchFeedMessages(omitLeakedReportTokens(messages), feed);
}
/** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
return new URL(
@@ -3,8 +3,8 @@
/**
* 聊天页「写作模式」深度研究进度注入。
*
* 把 `useResearchReportStream` 的流式状态插到 `deep_research_report`
* 工具结果之后、模型总结之前(进度步骤条 + report.md 的 write_file stub):
* 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的
* 虚拟消息(进度步骤条 + report.md 的 write_file stub):
*
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
@@ -26,16 +26,13 @@ import {
artifactPreviewKey,
useArtifactsOptional,
} from "@/components/workspace/artifacts/context";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
import {
buildResearchFeedMessages,
buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
RESEARCH_REPORT_OUTPUT_PATH,
useResearchReportStream,
} from "./use-research-report-stream";
import { isInterceptedWritingReportWrite } from "./writing-mode";
type FeedThreadLike = {
messages: Message[];
@@ -58,15 +55,10 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
[thread.values?.artifacts],
);
// 正文首 token 到达且仍在写入 → 主动展开右侧沙箱。思考阶段不打开,
// 避免「写入文件 report.md」出现在模型还在想的时候。
// 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟
// 预览,报告在沙箱里边写边流式渲染。
useEffect(() => {
if (
!research ||
research.status !== "active" ||
research.phase !== "writing" ||
!research.reportText.trim()
) {
if (!research || research.status !== "active" || !research.reportText.trim()) {
return;
}
if (autoOpenedJobRef.current === research.jobId) return;
@@ -85,15 +77,7 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
if (!artifacts) return;
handedOverJobRef.current = research.jobId;
const current = artifacts.selectedArtifact;
const virtualUrl = buildResearchReportVirtualUrl(research);
const currentPath = current ? resolveWriteFilePath(current) : "";
const keepUserSelection =
current !== null &&
current !== virtualUrl &&
current !== RESEARCH_REPORT_OUTPUT_PATH &&
// research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。
!isInterceptedWritingReportWrite(currentPath, undefined, undefined, true);
if (keepUserSelection) {
if (current !== null && current !== buildResearchReportVirtualUrl(research)) {
return;
}
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
@@ -107,11 +91,11 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
}, [hasRealArtifact, research]);
const displayThread = useMemo(() => {
const messages = composeResearchDisplayMessages(
thread.messages,
feedMessages as unknown as Message[],
);
if (messages === thread.messages) return thread;
if (feedMessages.length === 0) return thread;
const messages = [
...thread.messages,
...(feedMessages as unknown as Message[]),
];
return {
...thread,
values: { ...thread.values, messages },
@@ -230,7 +230,7 @@ function toRagSource(
* A single-RAG stream is a concatenation of source envelopes, for example:
* `{docs:["..."], recUuid:"...", m_title:"...", m_content:"..."}`.
* The root-level metadata is the source; `docs` merely marks the envelope and
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
*/
function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] {
const hasRootMetadata = [
+1 -13
View File
@@ -27,18 +27,6 @@ function pickAgentRef(
return undefined;
}
/**
* Return the agent that owns a thread when that ownership was persisted on
* either its run context or its metadata. Keeping this in one place ensures
* sidebars and route recovery make the same decision for older deep links.
*/
export function agentRefOfThread(thread: {
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
}): string | undefined {
return pickAgentRef(thread.context, thread.metadata);
}
export function pathOfThread(
thread: ThreadRouteTarget,
context?: ThreadAgentRef | null,
@@ -48,7 +36,7 @@ export function pathOfThread(
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
: pickAgentRef(thread.context, thread.metadata);
return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -1,135 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isInterceptedWritingReportWrite,
isWritingFirstReportTurn,
shouldAutoOpenWritingArtifact,
} from "./writing-mode";
test("custom outputs markdown is always intercepted except the research stub", () => {
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md", undefined, undefined, true),
true,
);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"research-report-j1",
undefined,
true,
),
false,
);
});
test("normal mode never intercepts outputs markdown writes", () => {
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"),
false,
);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m0", [
{ type: "human", content: "写一篇论文" },
]),
false,
);
});
test("report.md is intercepted for the whole first-report turn", () => {
const beforePipeline = [{ type: "human", content: "写一篇论文" }];
assert.equal(isWritingFirstReportTurn(beforePipeline), true);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"m1",
beforePipeline,
true,
),
true,
);
const inFlight = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(isWritingFirstReportTurn(inFlight), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight, true),
true,
);
const succeeded = [
...inFlight,
{
type: "tool",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md。",
},
];
assert.equal(isWritingFirstReportTurn(succeeded), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded, true),
true,
);
});
test("sandbox auto-open waits for report.md on the first-report turn", () => {
const firstTurn = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn, true),
false,
);
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn, true),
true,
);
});
test("normal mode auto-opens every new artifact like before writing mode existed", () => {
const firstTurn = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn),
true,
);
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn),
true,
);
});
test("a later user turn releases report.md live edits", () => {
const followUp = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
{
type: "tool",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md。",
},
{ type: "human", content: "把第二节改短一点" },
];
assert.equal(isWritingFirstReportTurn(followUp), false);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp, true),
false,
);
});
@@ -1,127 +0,0 @@
/** Only the writing-mode deliverable counts as a follow-up edit target. */
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
export function normalizeWritingPath(path: string | null | undefined): string {
if (!path) return "";
return path.replace(/\\/g, "/").split("?")[0]?.split("#")[0]?.replace(/\/+$/, "") ?? "";
}
export function isWritingModeReportPath(
path: string | null | undefined,
): boolean {
const normalized = normalizeWritingPath(path);
return /(?:^|\/)report\.md$/i.test(normalized);
}
/** Markdown under the sandbox outputs dir — writing mode intercepts these as the report. */
export function isOutputsMarkdownPath(path: string | null | undefined): boolean {
const normalized = normalizeWritingPath(path);
if (!normalized || !/\.(?:md|markdown)$/i.test(normalized)) return false;
return (
normalized === OUTPUTS_PREFIX ||
normalized.startsWith(`${OUTPUTS_PREFIX}/`) ||
normalized === "outputs" ||
normalized.startsWith("outputs/")
);
}
type WritingPipelineMessage = {
type?: string;
name?: string;
content?: unknown;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{ name?: string }>;
};
/**
* First-report turn: no successful pipeline yet, or the latest visible user
* message is still the one that triggered that pipeline. Follow-up edits
* (a later visible human after success) keep live `report.md` writes visible.
*/
export function isWritingFirstReportTurn(
messages: readonly WritingPipelineMessage[] | null | undefined,
): boolean {
if (!messages?.length) return false;
let lastVisibleHuman = -1;
let lastSuccess = -1;
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (
message?.type === "human" &&
message.additional_kwargs?.hide_from_ui !== true
) {
lastVisibleHuman = index;
}
if (message?.type === "tool" && message.name === "deep_research_report") {
const content = typeof message.content === "string" ? message.content : "";
if (content && !content.toLowerCase().startsWith("error")) {
lastSuccess = index;
}
}
}
return lastVisibleHuman >= 0 && (lastSuccess < 0 || lastVisibleHuman <= lastSuccess);
}
/**
* Hand-written outputs/*.md writes the backend rewrites or drops:
* custom filenames always, and `report.md` while the first-turn pipeline is
* in-flight or just succeeded. The injected research stub stays visible.
*
* Everything above only exists in writing mode — normal mode writes md files
* by hand as always, so `writingMode=false` (the default) never intercepts.
*/
export function isInterceptedWritingReportWrite(
path: string | null | undefined,
messageId?: string | null,
messages?: readonly WritingPipelineMessage[] | null,
writingMode = false,
): boolean {
if (!writingMode) {
return false;
}
if (typeof messageId === "string" && messageId.startsWith("research-report-")) {
return false;
}
if (!isOutputsMarkdownPath(path)) {
return false;
}
if (!isWritingModeReportPath(path)) {
return true;
}
return isWritingFirstReportTurn(messages);
}
/**
* Auto-open the sandbox only for the real report during the first-report
* turn. Normal mode keeps the pre-writing-mode behavior: always auto-open
* the newest artifact.
*/
export function shouldAutoOpenWritingArtifact(
path: string | null | undefined,
messages?: readonly WritingPipelineMessage[] | null,
writingMode = false,
): boolean {
if (!path) {
return false;
}
if (!writingMode) {
return true;
}
if (isWritingFirstReportTurn(messages)) {
return isWritingModeReportPath(path);
}
return !isInterceptedWritingReportWrite(path, undefined, messages, true);
}
export function resolveWritingModeReportPath(
artifacts: readonly string[] | null | undefined,
selectedArtifact?: string | null,
): string | undefined {
if (isWritingModeReportPath(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(artifacts ?? [])]
.reverse()
.find((path) => isWritingModeReportPath(path));
}
@@ -273,7 +273,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
hideHeaderSaveDraft
hideHeaderSettings
newTaskLabel="新建会话"
step3Mode="report-only"
hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
+1 -32
View File
@@ -3,13 +3,11 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DatabaseZap,
Replace,
SearchIcon,
SparklesIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -20,32 +18,17 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { getStoredAuth } from "@/core/auth";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge";
import { SkillAddressesPanel } from "./SkillAddressesPage";
const PAGE_SIZE = 10;
export default function AdminSkillsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const { skills, isLoading, error } = useAllSkillsAdmin(true);
const knowledgeBindings = useSkillKnowledgeBindings();
const activeTab = useMemo(() => {
const tab = searchParams.get("tab");
return tab === "addresses" || tab === "knowledge" ? tab : "skills";
}, [searchParams]);
const changeTab = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === "skills") next.delete("tab");
else next.set("tab", value);
setSearchParams(next, { replace: true });
};
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -73,8 +56,7 @@ export default function AdminSkillsPage() {
<WorkspaceHeader />
<WorkspaceBody>
<Tabs
value={activeTab}
onValueChange={changeTab}
defaultValue="skills"
className="flex size-full min-h-0 flex-col gap-0"
>
<div className="flex items-center justify-between gap-4 border-b px-6 pt-4">
@@ -89,10 +71,6 @@ export default function AdminSkillsPage() {
<Replace />
技能地址管理
</TabsTrigger>
<TabsTrigger value="knowledge">
<DatabaseZap />
技能到知识库管理
</TabsTrigger>
</TabsList>
</div>
</div>
@@ -148,9 +126,6 @@ export default function AdminSkillsPage() {
skill={skill}
currentUserId={currentUserId}
isAdmin
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
(binding) => binding.skill_name === skill.name,
)}
/>
))}
</>
@@ -189,12 +164,6 @@ export default function AdminSkillsPage() {
>
<SkillAddressesPanel />
</TabsContent>
<TabsContent
value="knowledge"
className="mt-0 min-h-0 flex-1 overflow-hidden"
>
<SkillKnowledgePanel />
</TabsContent>
</Tabs>
</WorkspaceBody>
</WorkspaceContainer>
+43 -143
View File
@@ -3,8 +3,6 @@
import {
BotIcon,
Compass,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PanelRightOpenIcon,
PlusSquare,
} from "lucide-react";
@@ -38,7 +36,6 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useTheme } from "@/contexts/ThemeContext";
import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useModels } from "@/core/models/hooks";
@@ -48,7 +45,6 @@ import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStrea
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
Select,
@@ -88,13 +84,10 @@ import {
AGENTFX_REPORT_JSON_OUTPUT_PATH,
notifyTaskReportSaved,
} from "@/strategy-components/lib/agentfx-report-schema";
import { cn } from "@/lib/utils";
import type { Message } from "@langchain/langgraph-sdk";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
/** 实验性页面研报智能体:发送当前界面明暗模式供其选择同色系 HTML 骨架。 */
const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
@@ -118,26 +111,6 @@ function readPendingKnowledgeSelection(agentId: string | undefined): string[] |
}
}
function readPendingAssistantKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return null;
const parsed = JSON.parse(raw) as {
agentId?: string;
assistantKnowledgeBaseIds?: unknown;
};
if (parsed.agentId !== agentId || !Array.isArray(parsed.assistantKnowledgeBaseIds)) {
return null;
}
return parsed.assistantKnowledgeBaseIds
.map((item) => String(item).trim())
.filter(Boolean);
} catch {
return null;
}
}
function stringifyForHiddenContext(value: unknown): string {
if (typeof value === "string") return value;
try {
@@ -249,12 +222,8 @@ export default function AgentChatPage({
} = {}) {
const routeBase = useRouteBase();
const { t } = useI18n();
const { baseMode } = useTheme();
const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter();
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
// 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。
const [agentSidebarOpen, setAgentSidebarOpen] = useState(true);
const { agent_id: agentIdFromPath } = useParams<{
agent_id: string;
@@ -354,17 +323,11 @@ export default function AgentChatPage({
const pendingKnowledgeSelection = useRef(
readPendingKnowledgeSelection(agent_id),
);
const pendingAssistantKnowledgeSelection = useRef(
readPendingAssistantKnowledgeSelection(agent_id),
);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
() => pendingKnowledgeSelection.current ?? [],
);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
() => pendingAssistantKnowledgeSelection.current ?? [],
);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
() => pendingKnowledgeSelection.current !== null,
);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
@@ -375,10 +338,6 @@ export default function AgentChatPage({
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedAssistantKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
useEffect(() => {
activeThreadIdRef.current = threadId;
@@ -388,23 +347,17 @@ export default function AgentChatPage({
if (knowledgeSelectionAgent.current === agent_id) return;
knowledgeSelectionAgent.current = agent_id;
const pending = readPendingKnowledgeSelection(agent_id);
const pendingAssistant = readPendingAssistantKnowledgeSelection(agent_id);
pendingKnowledgeSelection.current = pending;
pendingAssistantKnowledgeSelection.current = pendingAssistant;
knowledgeSelectionThread.current = null;
setSelectedKnowledgeBaseIds(pending ?? []);
setSelectedAssistantKnowledgeBaseIds(pendingAssistant ?? []);
setKnowledgeSelectionReady(pending !== null || pendingAssistant !== null);
setKnowledgeSelectionReady(pending !== null);
}, [agent_id]);
useEffect(() => {
if (isNewThread) {
if (knowledgeSelectionThread.current !== "__new__") {
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
setSelectedAssistantKnowledgeBaseIds(pendingAssistantKnowledgeSelection.current ?? []);
setKnowledgeSelectionReady(
pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
);
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
}
knowledgeSelectionThread.current = "__new__";
setKnowledgeSelectionLoading(false);
@@ -419,29 +372,18 @@ export default function AgentChatPage({
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
const assistantRaw = metadata?.assistant_knowledge_base_ids;
const llmwikiIds = Array.isArray(raw)
? raw.map((item) => String(item).trim()).filter(Boolean)
: [];
const assistantIds = Array.isArray(assistantRaw)
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
: [];
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(llmwikiIds);
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
if (Array.isArray(assistantRaw)) {
setSelectedAssistantKnowledgeBaseIds(assistantIds);
} else {
setSelectedAssistantKnowledgeBaseIds([]);
}
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
@@ -464,13 +406,10 @@ export default function AgentChatPage({
() => ({
...(taskId ? taskThreadMetadata(taskContext) : {}),
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
}),
[knowledgeSelectionReady, selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, taskContext, taskId],
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
);
// 任务工作区(taskBind):每次运行都带隐藏任务上下文(任务id/名称/内容),
// 新建对话的消息历史里没有开场信息时模型也能拿到任务id(见 taskBindRunContext)。
@@ -486,10 +425,7 @@ export default function AgentChatPage({
...settings.context,
agent_id,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
...(taskBindRunContextValue ?? {}),
},
@@ -676,13 +612,16 @@ export default function AgentChatPage({
} as typeof displayThread;
}, [displayThread, showReportImportCard, taskId, threadId]);
// 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover
// .md 不得跳过管线(与 ChatPage 对齐)。
const writingArtifactPath = useMemo(
() => resolveWritingModeReportPath(thread.values.artifacts),
[thread.values.artifacts],
);
// 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与
// ChatPage 的回传逻辑对齐)。
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [thread.values.artifacts]);
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
@@ -734,18 +673,13 @@ export default function AgentChatPage({
settings.context.writing_mode && writingArtifactPath
? { writing_artifact_path: writingArtifactPath }
: {};
const pageReportThemeContext =
agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
? { presentation_theme: baseMode }
: {};
void sendMessage(threadId, message, {
...writingExtraContext,
...(extraContext ?? {}),
agent_id,
...pageReportThemeContext,
});
},
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath],
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -819,14 +753,9 @@ export default function AgentChatPage({
async (answer: string) => {
setHasStartedConversation(true);
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] }, {
agent_id,
...(agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
? { presentation_theme: baseMode }
: {}),
});
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
},
[sendMessage, thread, threadId, agent_id, baseMode],
[sendMessage, thread, threadId, agent_id],
);
const handleStop = useCallback(async () => {
@@ -867,11 +796,7 @@ export default function AgentChatPage({
{showAgentList && (
<div
data-agent-guide="chat-sidebar"
aria-hidden={!agentSidebarOpen}
className={cn(
"flex h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
agentSidebarOpen ? "w-(--sidebar-width)" : "pointer-events-none w-0",
)}
className="flex h-full shrink-0"
>
<AgentChatSidebar activeAgentId={agent_id} />
</div>
@@ -883,25 +808,6 @@ export default function AgentChatPage({
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
{/* Header in normal flow, fixed height. */}
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
{/* 智能体列表栏的本地收展入口。 */}
{showAgentList && !isEmbedded && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setAgentSidebarOpen((open) => !open)}
title={agentSidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-label={agentSidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-expanded={agentSidebarOpen}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{agentSidebarOpen ? (
<PanelLeftCloseIcon className="size-4" />
) : (
<PanelLeftOpenIcon className="size-4" />
)}
</Button>
)}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span
@@ -977,6 +883,23 @@ export default function AgentChatPage({
开始回答
</Button>
)}
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
{/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示
(展开态的「收起」按钮在面板右上角)。桌面端专用(lg+),
窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */}
@@ -998,23 +921,6 @@ export default function AgentChatPage({
enabled={tokenUsageEnabled}
messages={displayThread.messages}
/>
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
@@ -1074,13 +980,9 @@ export default function AgentChatPage({
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
autoSelectAll={
isNewThread &&
pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null
isNewThread && pendingKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
@@ -1165,9 +1067,7 @@ export default function AgentChatPage({
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
readOnly
label="知识空间"
+15 -5
View File
@@ -1,5 +1,15 @@
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
export default function AgentsPage() {
return <AgentGallery />;
}
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
export default function AgentsPage() {
return (
<WorkspaceContainer className="bg-white dark:bg-slate-950">
<WorkspaceBody>
<AgentGallery />
</WorkspaceBody>
</WorkspaceContainer>
);
}
@@ -1,559 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
ArrowDownWideNarrow,
Bot,
ChevronLeft,
ChevronRight,
Download,
FileText,
FileX2,
Library,
Loader2,
MessagesSquare,
MessageSquareShare,
RefreshCw,
Search,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import {
downloadArtifactFile,
downloadArtifactMarkdownAsWord,
} from "@/core/artifacts/download";
import {
fetchArtifactLibrary,
type ArtifactLibraryItem,
type ArtifactLibraryKind,
type ArtifactLibrarySource,
} from "@/core/artifacts/library";
import { useI18n } from "@/core/i18n/hooks";
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
import { formatDateTime } from "@/core/utils/datetime";
import {
getFileExtensionDisplayName,
getFileIcon,
} from "@/core/utils/files";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 50;
const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [
{ value: "all", label: "全部" },
{ value: "document", label: "文档" },
{ value: "spreadsheet", label: "表格" },
{ value: "text_data", label: "文本与数据" },
{ value: "archive", label: "压缩包" },
{ value: "other", label: "其他" },
];
const SOURCE_FILTERS: Array<{
value: ArtifactLibrarySource;
label: string;
}> = [
{ value: "all", label: "全部来源" },
{ value: "general_qa", label: "通用问答" },
{ value: "agent", label: "智能体" },
];
type DownloadFormat = "original" | "word";
function isMarkdownArtifact(name: string): boolean {
return /\.(md|markdown)$/i.test(name);
}
function artifactDownloadKey(
item: ArtifactLibraryItem,
format: DownloadFormat,
): string {
return `${item.threadId}:${item.path}:${format}`;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** index;
const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(digits)} ${units[index]}`;
}
function sourceConversationPath(item: ArtifactLibraryItem): string {
const basePath =
item.threadType === "bootstrap"
? bootstrapResumePath(item.threadId)
: pathOfThread({
thread_id: item.threadId,
metadata: item.agentId ? { agent_id: item.agentId } : undefined,
});
const search = new URLSearchParams({ artifact: item.path });
return `${basePath}?${search.toString()}`;
}
function FileRow({
item,
downloading,
onDownload,
}: {
item: ArtifactLibraryItem;
downloading: DownloadFormat | null;
onDownload: (item: ArtifactLibraryItem, format: DownloadFormat) => void;
}) {
const sourcePath = sourceConversationPath(item);
const displayName = desensitize(item.name);
const sourceTitle = desensitize(item.threadTitle || "新对话");
const sourceLabel = item.sourceType === "agent" ? "智能体" : "通用问答";
const sourceName =
item.sourceType === "agent"
? desensitize(item.agentName || item.agentId || "未知智能体")
: sourceLabel;
const SourceIcon = item.sourceType === "agent" ? Bot : MessagesSquare;
const busy = downloading !== null;
return (
<tr className="group border-b transition-colors hover:bg-muted/35">
<td className="min-w-0 py-3 pr-4 pl-1">
<Link
href={sourcePath}
className="flex min-w-0 items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={`打开原对话并查看 ${displayName}`}
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground/75">
{getFileIcon(item.name, "size-5")}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground group-hover:text-primary">
{displayName}
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground lg:hidden">
{getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「
{sourceTitle}」
</span>
<span className="mt-1 hidden text-xs text-muted-foreground lg:block">
{getFileExtensionDisplayName(item.name)} 文件
</span>
</span>
</Link>
</td>
<td className="hidden min-w-0 px-3 py-3 lg:table-cell">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<SourceIcon className="size-4" />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{sourceName}
</span>
<Link
href={sourcePath}
className="block truncate text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
title={`打开原对话:${sourceTitle}`}
>
{item.sourceType === "agent" ? "智能体 · " : ""}
{sourceTitle}
</Link>
</span>
</div>
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-muted-foreground">
{formatDateTime(item.modifiedAt)}
</td>
<td className="hidden whitespace-nowrap px-3 py-3 text-sm text-muted-foreground md:table-cell">
{formatBytes(item.sizeBytes)}
</td>
<td className="py-3 pr-1 pl-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
type="button"
size="icon"
variant="ghost"
className="size-8"
disabled={busy}
onClick={() => onDownload(item, "original")}
title={`下载原文件:${displayName}`}
aria-label={`下载原文件:${displayName}`}
>
{downloading === "original" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
</Button>
{isMarkdownArtifact(item.name) && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 gap-1 px-2 text-xs"
disabled={busy}
onClick={() => onDownload(item, "word")}
title={`下载为 Word:${displayName}`}
aria-label={`下载为 Word:${displayName}`}
>
{downloading === "word" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileText className="size-4" />
)}
Word
</Button>
)}
<Button
asChild
size="icon"
variant="ghost"
className="size-8"
title={`打开原对话:${sourceTitle}`}
>
<Link href={sourcePath} aria-label={`打开原对话:${sourceTitle}`}>
<MessageSquareShare className="size-4" />
</Link>
</Button>
</div>
</td>
</tr>
);
}
function LoadingRows() {
return Array.from({ length: 5 }, (_, index) => (
<tr className="border-b" key={index}>
<td className="py-3 pr-4 pl-1">
<div className="flex items-center gap-3">
<Skeleton className="size-10 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
</td>
<td className="hidden px-3 lg:table-cell"><Skeleton className="h-4 w-3/4" /></td>
<td className="px-3"><Skeleton className="h-4 w-24" /></td>
<td className="hidden px-3 md:table-cell"><Skeleton className="h-4 w-16" /></td>
<td className="pr-1 pl-3"><Skeleton className="ml-auto size-8" /></td>
</tr>
));
}
export default function ArtifactLibraryPage() {
const { t } = useI18n();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
const [sourceType, setSourceType] = useState<ArtifactLibrarySource>("all");
const [page, setPage] = useState(1);
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const forceRefreshRef = useRef(false);
useEffect(() => {
document.title = `资料库 - ${t.pages.appName}`;
}, [t.pages.appName]);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => window.clearTimeout(timer);
}, [search]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, fileKind, sourceType]);
const offset = (page - 1) * PAGE_SIZE;
const libraryQuery = useQuery({
queryKey: ["artifact-library", debouncedSearch, fileKind, sourceType, page],
queryFn: async ({ signal }) => {
const refresh = forceRefreshRef.current;
try {
return await fetchArtifactLibrary({
query: debouncedSearch,
fileKind,
sourceType,
offset,
limit: PAGE_SIZE,
refresh,
signal,
});
} finally {
forceRefreshRef.current = false;
}
},
retry: false,
});
const total = libraryQuery.data?.total ?? 0;
const items = libraryQuery.data?.items ?? [];
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const hasPrevious = page > 1;
const hasNext = page < totalPages;
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const itemKeys = useMemo(
() =>
new Set(
items.flatMap((item) => [
artifactDownloadKey(item, "original"),
artifactDownloadKey(item, "word"),
]),
),
[items],
);
useEffect(() => {
if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null);
}, [downloadingKey, itemKeys]);
const handleDownload = async (
item: ArtifactLibraryItem,
format: DownloadFormat,
) => {
const key = artifactDownloadKey(item, format);
if (downloadingKey) return;
setDownloadingKey(key);
try {
if (format === "word") {
await downloadArtifactMarkdownAsWord({
filepath: item.path,
threadId: item.threadId,
});
toast.success("Word 文件已生成");
} else {
await downloadArtifactFile({
filepath: item.path,
threadId: item.threadId,
});
}
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : "下载失败");
} finally {
setDownloadingKey(null);
}
};
const handleRefresh = () => {
forceRefreshRef.current = true;
void libraryQuery.refetch();
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody className="items-stretch">
<div className="flex min-h-0 w-full flex-1 flex-col">
<header className="shrink-0 border-b bg-card/40 px-5 py-6 sm:px-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<div className="flex items-center gap-2">
<Library className="size-6 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
</div>
<p className="mt-2 text-sm text-muted-foreground">
汇总当前账号在对话中生成的文件,不包含上传文件、图片、音频和视频。
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={libraryQuery.isFetching}
>
<RefreshCw className={cn("size-4", libraryQuery.isFetching && "animate-spin")} />
刷新
</Button>
</div>
<div className="relative max-w-xl">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
className="h-11 pl-9"
placeholder="搜索文件名、来源对话或智能体名称"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-1" aria-label="文件类型筛选">
{FILTERS.map((filter) => (
<Button
key={filter.value}
type="button"
size="sm"
variant={fileKind === filter.value ? "secondary" : "ghost"}
aria-pressed={fileKind === filter.value}
onClick={() => setFileKind(filter.value)}
>
{filter.label}
</Button>
))}
</div>
<Select
value={sourceType}
onValueChange={(value) =>
setSourceType(value as ArtifactLibrarySource)
}
>
<SelectTrigger
size="sm"
className="w-32"
aria-label="筛选文件来源"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOURCE_FILTERS.map((filter) => (
<SelectItem key={filter.value} value={filter.value}>
{filter.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ArrowDownWideNarrow className="size-4" />
最近生成
</div>
</div>
</div>
</header>
<ScrollArea className="min-h-0 flex-1">
<main className="mx-auto w-full max-w-6xl px-5 py-5 sm:px-8">
<div className="mb-2 text-xs text-muted-foreground tabular-nums">
{libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`}
</div>
<div className="overflow-x-auto">
<table className="w-full table-fixed border-collapse">
<thead>
<tr className="border-b text-left text-xs font-normal text-muted-foreground">
<th className="w-[48%] py-3 pr-4 pl-1 font-normal lg:w-[35%]">文件名</th>
<th className="hidden w-[25%] px-3 py-3 font-normal lg:table-cell">来源</th>
<th className="w-[34%] px-3 py-3 font-normal md:w-[22%] lg:w-[16%]">生成时间</th>
<th className="hidden w-[13%] px-3 py-3 font-normal lg:w-[10%] md:table-cell">大小</th>
<th className="w-[18%] py-3 pr-1 pl-3 text-right font-normal md:w-[17%] lg:w-[14%]">
<span className="sr-only">操作</span>
</th>
</tr>
</thead>
<tbody>
{libraryQuery.isLoading ? (
<LoadingRows />
) : (
items.map((item) => {
const key = `${item.threadId}:${item.path}`;
const activeDownload = downloadingKey?.startsWith(`${key}:`)
? downloadingKey.endsWith(":word")
? "word"
: "original"
: null;
return (
<FileRow
key={key}
item={item}
downloading={activeDownload}
onDownload={(target, format) =>
void handleDownload(target, format)
}
/>
);
})
)}
</tbody>
</table>
</div>
{!libraryQuery.isLoading && libraryQuery.isError && (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<FileX2 className="size-9 text-muted-foreground" />
<div>
<p className="text-sm font-medium">资料库加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">
{libraryQuery.error instanceof Error
? libraryQuery.error.message
: "请稍后重试"}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
重新加载
</Button>
</div>
)}
{!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && (
<div className="flex flex-col items-center gap-3 py-20 text-center">
<FileX2 className="size-9 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{debouncedSearch || fileKind !== "all" || sourceType !== "all"
? "没有找到匹配的文件"
: "还没有生成文件"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
对话中生成的文档、表格和数据文件会自动出现在这里。
</p>
</div>
</div>
)}
{!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && (
<div className="flex items-center justify-center gap-3 py-7">
<Button
variant="outline"
size="sm"
disabled={!hasPrevious}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
<ChevronLeft className="size-4" />
上一页
</Button>
<span className="text-sm text-muted-foreground tabular-nums">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasNext}
onClick={() => setPage((current) => current + 1)}
>
下一页
<ChevronRight className="size-4" />
</Button>
</div>
)}
</main>
</ScrollArea>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
@@ -1,448 +0,0 @@
import { BookOpen, Database, Download, Loader2, Plus, RefreshCw, Search, ShieldCheck, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getStoredAuth } from "@/core/auth";
import {
type AssistantKnowledgeBase,
type AssistantWikiPage,
useAssistantImportJobs,
useAssistantKnowledgeActions,
useAssistantKnowledgeBases,
useAssistantKnowledgeSources,
useAssistantWikiPages,
} from "@/strategy-components/api/assistant-knowledge";
function baseSubtitle(base: AssistantKnowledgeBase) {
if (base.is_global) return "全库沉淀总库 · 所有 WeKnora 导入数据都会同步进入这里并做实体对齐";
return base.description || "自建助手知识库 · 可上传普通知识库导出的 Wiki 向量包";
}
function EmptyInitializeCard({
isAdmin,
onInitialize,
pending,
}: {
isAdmin: boolean;
onInitialize: () => void;
pending: boolean;
}) {
return (
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-lg rounded-xl border bg-card p-8 text-center">
<Database className="mx-auto mb-4 size-10 text-primary" />
<h1 className="text-xl font-semibold">初始化助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground">
未配置 WeKnora 时会默认展示助手知识库。首次使用需要创建“知识梳理总库”,后续从 WeKnora 导入的数据都会沉淀到这里。
</p>
{isAdmin ? (
<Button className="mt-5" disabled={pending} onClick={onInitialize}>
{pending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Database className="mr-2 size-4" />}
创建知识梳理总库
</Button>
) : (
<p className="mt-5 text-sm">请联系管理员初始化。</p>
)}
</div>
</div>
);
}
export default function AssistantKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [query, setQuery] = useState("");
const [selectedBaseId, setSelectedBaseId] = useState<string | null>(null);
const [selected, setSelected] = useState<AssistantWikiPage | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createDraft, setCreateDraft] = useState({ name: "", description: "" });
const packageInputRef = useRef<HTMLInputElement | null>(null);
const bases = useAssistantKnowledgeBases();
const actions = useAssistantKnowledgeActions();
useEffect(() => {
const values = bases.data ?? [];
if (!values.length) {
setSelectedBaseId(null);
return;
}
if (selectedBaseId && values.some((base) => base.id === selectedBaseId)) return;
setSelectedBaseId((values.find((base) => base.is_global) ?? values[0]).id);
}, [bases.data, selectedBaseId]);
useEffect(() => {
setSelected(null);
}, [selectedBaseId]);
const selectedBase = useMemo(
() => (bases.data ?? []).find((base) => base.id === selectedBaseId) ?? null,
[bases.data, selectedBaseId],
);
const pages = useAssistantWikiPages(query, selectedBaseId);
const sources = useAssistantKnowledgeSources(selectedBaseId);
const jobs = useAssistantImportJobs(selectedBaseId);
const activePage = selected ?? pages.data?.[0] ?? null;
const counts = useMemo(
() => ({
pages: pages.data?.length ?? selectedBase?.page_count ?? 0,
sources: sources.data?.length ?? selectedBase?.source_count ?? 0,
jobs: jobs.data?.length ?? 0,
}),
[jobs.data, pages.data, selectedBase?.page_count, selectedBase?.source_count, sources.data],
);
const initializeGlobal = () => {
actions.initialize.mutate("知识梳理总库", {
onSuccess: (base) => {
setSelectedBaseId(base.id);
toast.success("知识梳理总库已创建");
},
onError: (error) => toast.error(error.message),
});
};
const createBase = async () => {
const name = createDraft.name.trim();
if (!name) {
toast.error("请输入助手知识库名称");
return;
}
try {
const base = await actions.createBase.mutateAsync({
name,
description: createDraft.description.trim(),
});
setSelectedBaseId(base.id);
setCreateDraft({ name: "", description: "" });
setCreateOpen(false);
toast.success("助手知识库已创建");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
const uploadPackage = async (file: File | null | undefined) => {
if (!file) return;
try {
await actions.uploadWeKnoraPackage.mutateAsync({
file,
assistantBaseId: selectedBaseId,
});
toast.success("导出包已上传,后台正在异步导入");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
if (packageInputRef.current) packageInputRef.current.value = "";
}
};
if (bases.isLoading) {
return (
<div className="flex size-full flex-col">
<KnowledgeProductNav />
<div className="flex flex-1 items-center justify-center">
<Loader2 className="animate-spin" />
</div>
</div>
);
}
if (!bases.data?.length) {
return (
<div className="flex size-full flex-col">
<KnowledgeProductNav />
<EmptyInitializeCard
isAdmin={isAdmin}
pending={actions.initialize.isPending}
onInitialize={initializeGlobal}
/>
</div>
);
}
return (
<div className="flex size-full min-h-0 flex-col bg-background">
<KnowledgeProductNav />
<div className="grid min-h-0 flex-1 grid-cols-[280px_1fr]">
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold">助手知识库</h2>
<p className="mt-1 text-xs text-muted-foreground">总库置顶,自建库可上传 WeKnora 导出包</p>
</div>
{isAdmin && (
<Button size="icon" variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
</Button>
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 p-3">
{(bases.data ?? []).map((base) => (
<button
key={base.id}
type="button"
onClick={() => setSelectedBaseId(base.id)}
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
selectedBaseId === base.id ? "border-primary bg-primary/10 text-primary" : "bg-card hover:bg-muted"
}`}
>
<div className="flex items-center gap-2">
<Database className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{base.name}</span>
{base.is_global && <Badge variant="outline" className="text-[10px]">总库</Badge>}
</div>
<div className="mt-2 flex gap-3 text-[11px] text-muted-foreground">
<span>{base.page_count ?? 0} 页</span>
<span>{base.source_count ?? 0} 来源</span>
<span>{base.entity_count ?? 0} 实体</span>
</div>
</button>
))}
</div>
</ScrollArea>
</aside>
<main className="flex min-h-0 flex-col">
<header className="flex items-center gap-4 border-b px-6 py-4">
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold">{selectedBase?.name}</h1>
<p className="mt-1 truncate text-xs text-muted-foreground">
{selectedBase ? baseSubtitle(selectedBase) : "请选择助手知识库"}
</p>
</div>
<Badge variant="outline">
<ShieldCheck className="mr-1 size-3" />
实体对齐
</Badge>
{isAdmin && (
<div className="ml-auto flex items-center gap-2">
<input
ref={packageInputRef}
type="file"
accept=".jsonl,.ndjson,application/x-ndjson,application/json"
className="hidden"
onChange={(event) => void uploadPackage(event.target.files?.[0])}
/>
<Button
variant="outline"
disabled={actions.uploadWeKnoraPackage.isPending || !selectedBaseId}
onClick={() => packageInputRef.current?.click()}
>
{actions.uploadWeKnoraPackage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
上传 WeKnora 导出包
</Button>
</div>
)}
</header>
<Tabs defaultValue="wiki" className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-4 border-b px-6">
<TabsList className="my-2">
<TabsTrigger value="wiki">Wiki({counts.pages})</TabsTrigger>
<TabsTrigger value="sources">来源({counts.sources})</TabsTrigger>
<TabsTrigger value="jobs">导入记录({counts.jobs})</TabsTrigger>
</TabsList>
<div className="relative ml-auto w-72">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索标题、实体别名和正文"
className="pl-8"
/>
</div>
</div>
<TabsContent value="wiki" className="mt-0 grid min-h-0 flex-1 grid-cols-[300px_1fr]">
<ScrollArea className="border-r">
<div className="space-y-1 p-3">
{pages.isLoading && <Loader2 className="m-4 size-5 animate-spin" />}
{pages.data?.map((page) => (
<button
key={page.id}
type="button"
onClick={() => setSelected(page)}
className={`w-full rounded-md px-3 py-2 text-left ${
activePage?.id === page.id ? "bg-primary/10 text-primary" : "hover:bg-muted"
}`}
>
<div className="truncate text-sm font-medium">{page.title}</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{page.page_type}</span>
<span>{new Date(page.updated_at).toLocaleString()}</span>
</div>
</button>
))}
{!pages.isLoading && !pages.data?.length && (
<div className="p-4 text-xs text-muted-foreground">
暂无 Wiki 页面。请先到普通知识库详情页下载向量化数据包,再在当前助手知识库上传。
</div>
)}
</div>
</ScrollArea>
<ScrollArea>
<article className="mx-auto max-w-5xl p-8">
{activePage ? (
<>
<h1 className="mb-1 flex items-center gap-2 text-2xl font-semibold">
<BookOpen className="size-5 text-primary" />
{activePage.title}
</h1>
<p className="mb-6 text-xs text-muted-foreground">{activePage.slug}</p>
<MarkdownContent content={activePage.content_markdown} isLoading={false} rehypePlugins={[]} />
</>
) : (
<p className="text-sm text-muted-foreground">暂无 Wiki 页面。</p>
)}
</article>
</ScrollArea>
</TabsContent>
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{sources.data?.map((source) => (
<div key={source.id} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Database className="size-4" />
<span className="truncate font-medium">{source.source_name}</span>
<Badge variant="outline" className="ml-auto">{source.source_type}</Badge>
</div>
<p className="mt-3 text-xs text-muted-foreground">业务键:{source.source_key}</p>
{isAdmin && (
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={source.source_type !== "weknora" || actions.reimportSource.isPending}
onClick={() =>
actions.reimportSource.mutate(source.id, {
onSuccess: () => toast.success("已发起重新导入"),
onError: (error) => toast.error(error.message),
})
}
>
<RefreshCw className="mr-2 size-4" />
重新导入
</Button>
<Button
size="sm"
variant="destructive"
disabled={actions.deleteSource.isPending}
onClick={() => {
if (window.confirm(`确认删除来源“${source.source_name}”的贡献?仍被其他来源引用的知识会保留。`)) {
actions.deleteSource.mutate(source.id, {
onSuccess: () => toast.success("来源贡献已删除"),
onError: (error) => toast.error(error.message),
});
}
}}
>
<Trash2 className="mr-2 size-4" />
删除来源
</Button>
</div>
)}
</div>
))}
{!sources.data?.length && <p className="text-sm text-muted-foreground">暂无来源。</p>}
</div>
</TabsContent>
<TabsContent value="jobs" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<div className="space-y-3">
{jobs.data?.map((job) => (
<div key={job.id} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<Badge>{job.status}</Badge>
<span className="text-sm">{job.trigger}</span>
<span className="text-xs text-muted-foreground">阶段:{job.phase}</span>
<span className="ml-auto text-xs">{new Date(job.created_at).toLocaleString()}</span>
{isAdmin && job.package_download_url && (
<Button asChild size="sm" variant="outline">
<a href={job.package_download_url} target="_blank" rel="noreferrer">
<Download className="mr-2 size-4" />
下载导出包
</a>
</Button>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ") || "暂无统计"}
</div>
{job.last_error_message && (
<div className="mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{job.last_error_message}
</div>
)}
</div>
))}
{!jobs.data?.length && <p className="text-sm text-muted-foreground">暂无导入记录。</p>}
</div>
</TabsContent>
</Tabs>
</main>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>新建助手知识库</DialogTitle>
<DialogDescription>
自建助手知识库用于承接从 WeKnora 普通知识库下载的 Wiki 向量包;导入时同一份数据也会沉淀到“知识梳理总库”。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">名称</label>
<Input
value={createDraft.name}
onChange={(event) => setCreateDraft((prev) => ({ ...prev, name: event.target.value }))}
placeholder="例如:政策资料库"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">描述</label>
<Textarea
value={createDraft.description}
onChange={(event) => setCreateDraft((prev) => ({ ...prev, description: event.target.value }))}
placeholder="可选,用于说明这个助手知识库的范围"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
取消
</Button>
<Button disabled={actions.createBase.isPending} onClick={() => void createBase()}>
{actions.createBase.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Plus className="mr-2 size-4" />}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+35 -104
View File
@@ -38,16 +38,12 @@ import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import {
buildFullChatNavigationPath,
isChatsIframeWorkspacePath,
} from "@/core/page-layout/chats-iframe-route";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie";
import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { MessageSquarePlus, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
@@ -67,7 +63,6 @@ import {
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
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";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -118,7 +113,6 @@ function clearBrowserContextParamsFromHash() {
export default function ChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const navigate = useNavigate();
const searchParams = useSearchParams();
const pathname = usePathname();
// `chatMode=full` is used by an embedded standard chat. It intentionally
@@ -142,7 +136,6 @@ export default function ChatPage() {
// per-thread header toggle so stale localStorage can't re-enable it.
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
@@ -151,26 +144,18 @@ export default function ChatPage() {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedAssistantKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const streamContext = useMemo(
() => ({
...settings.context,
knowledge_rag_enabled: knowledgeRagEnabled,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
}),
[
settings.context,
knowledgeRagEnabled,
knowledgeSelectionReady,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
],
);
@@ -209,7 +194,6 @@ export default function ChatPage() {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
setKnowledgeSelectionLoading(false);
knowledgeSelectionThread.current = "__new__";
@@ -235,29 +219,18 @@ export default function ChatPage() {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
const assistantRaw = metadata?.assistant_knowledge_base_ids;
const llmwikiIds = Array.isArray(raw)
? raw.map((item) => String(item).trim()).filter(Boolean)
: [];
const assistantIds = Array.isArray(assistantRaw)
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
: [];
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(llmwikiIds);
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
if (Array.isArray(assistantRaw)) {
setSelectedAssistantKnowledgeBaseIds(assistantIds);
} else {
setSelectedAssistantKnowledgeBaseIds([]);
}
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
@@ -280,7 +253,6 @@ export default function ChatPage() {
if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
@@ -291,10 +263,7 @@ export default function ChatPage() {
threadId: isNewThread ? undefined : threadId,
context: streamContext,
threadMetadata: knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: undefined,
isMock,
onStart: (createdThreadId) => {
@@ -385,11 +354,16 @@ export default function ChatPage() {
researchDisplayThread,
]);
const writingArtifactPath = useMemo(
() =>
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
[selectedArtifact, thread.values.artifacts],
);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
@@ -468,10 +442,7 @@ export default function ChatPage() {
metadata: {
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
},
});
@@ -482,10 +453,7 @@ export default function ChatPage() {
threadId: created.thread_id,
text,
...(knowledgeSelectionReady
? {
knowledgeBaseIds: selectedKnowledgeBaseIds,
assistantKnowledgeBaseIds: selectedAssistantKnowledgeBaseIds,
}
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
: {}),
}),
);
@@ -547,7 +515,6 @@ export default function ChatPage() {
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
agentHandoffPending,
@@ -651,7 +618,6 @@ export default function ChatPage() {
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
saveLastAgentId(agentId);
}, []);
@@ -709,20 +675,6 @@ export default function ChatPage() {
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
const compactActionsHidden = isHideChromeActive();
const headerNewChatPath = useMemo(
() =>
buildFullChatNavigationPath(
`${routeBase}/chats/new`,
searchParams.toString(),
),
[routeBase, searchParams],
);
const handleHeaderNewChat = useCallback(() => {
navigate(headerNewChatPath, {
replace: true,
state: { resetAt: Date.now() },
});
}, [headerNewChatPath, navigate]);
// 简洁模式新手引导:仅面向「无全局导航栏/侧边栏」的正常简洁布局。iframe 嵌入
// (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有
// 全局 chrome,但不该在外部系统里弹引导。
@@ -761,47 +713,27 @@ export default function ChatPage() {
)}
>
<div className="flex min-w-0 flex-1 items-center overflow-hidden text-sm font-medium">
{/* 左上角始终提供左栏切换;展开态显示“收起”,收起态显示“展开”。 */}
{compactChrome && !compactActionsHidden && (
<ThreadTitle threadId={threadId} thread={displayThread} />
</div>
<div className="flex shrink-0 items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={toggleHistorySidebar}
title={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-label={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
className="mr-1 shrink-0 text-muted-foreground hover:text-foreground"
title="打开历史记录"
aria-label="打开历史记录"
className="text-muted-foreground hover:text-foreground"
>
{historySidebarOpen ? (
<PanelLeftClose className="size-4" />
) : (
<PanelLeftOpen className="size-4" />
)}
<PanelLeftOpen className="size-4" />
</Button>
)}
<ThreadTitle
className="-translate-y-1"
threadId={threadId}
thread={displayThread}
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={displayThread.messages}
/>
{compactChrome && !compactActionsHidden && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleHeaderNewChat}
className="text-muted-foreground hover:text-foreground"
>
<MessageSquarePlus className="size-4" />
新建对话
</Button>
)}
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger />
@@ -869,10 +801,11 @@ export default function ChatPage() {
isLandingState &&
!compactLanding &&
"-translate-y-[calc(50vh-96px)]",
// 新对话落地态在全量与简洁模式使用同一输入框宽度。
isLandingState
? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)",
compactLanding
? "max-w-[calc(var(--container-width-xl)+150px)]"
: isLandingState
? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
@@ -940,9 +873,7 @@ export default function ChatPage() {
source={selectedAgentId ? "agent" : "all"}
agentId={selectedAgentId}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
autoSelectAll={isLandingState && !!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
+117 -447
View File
@@ -3,30 +3,21 @@
import {
ChevronLeft,
ChevronRight,
Compass,
Clock,
DownloadIcon,
MessageSquare,
PenTool,
Pin,
PinOff,
Search,
Star,
Users2,
type LucideIcon,
X,
} from "lucide-react";
import Link from "next/link";
import { motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
CompactShortcutButtons,
LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls";
import {
WorkspaceBody,
@@ -50,70 +41,9 @@ import {
import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { useResearchSessions } from "@/strategy-components/api/deep-research";
import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab";
import { WritingHistoryTab } from "./chats-history/WritingHistoryTab";
import { ResearchHistoryTab } from "./chats-history/ResearchHistoryTab";
const PAGE_SIZE = 20;
/**
* 「对话历史」页的四个历史 Tab:对话(通用问答/智能体会话)之外,把多智能体
* 会商、AI写作、深度研究的记录也收纳进来(简洁模式侧栏不再展示后两者的入口,
* 统一从这里查看)。
*/
type HistoryTab = "chats" | "roundtable" | "writing" | "research";
type ChatFilter = "all" | "favorites" | "pinned";
const HISTORY_TABS: {
id: HistoryTab;
label: string;
description: string;
icon: LucideIcon;
}[] = [
{
id: "chats",
label: "对话",
description: "普通问答与智能体会话",
icon: MessageSquare,
},
{
id: "roundtable",
label: "多智能体会商",
description: "多角色协作研讨",
icon: Users2,
},
{
id: "writing",
label: "AI写作",
description: "写作任务与草稿",
icon: PenTool,
},
{
id: "research",
label: "深度研究",
description: "研究任务与报告",
icon: Compass,
},
];
/** 非对话 Tab 的搜索占位文案(对话 Tab 沿用 i18n 的 t.chats.searchChats)。 */
const TAB_SEARCH_PLACEHOLDER: Record<Exclude<HistoryTab, "chats">, string> = {
roundtable: "搜索会商记录…",
writing: "搜索写作记录…",
research: "搜索研究记录…",
};
const TAB_TOTAL_UNIT: Record<HistoryTab, string> = {
chats: "条对话",
roundtable: "条会商记录",
writing: "篇写作",
research: "项研究",
};
export default function ChatsPage({
compactChrome = false,
}: {
@@ -123,28 +53,16 @@ export default function ChatsPage({
const { t } = useI18n();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [activeTab, setActiveTab] = useState<HistoryTab>("chats");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [chatFilter, setChatFilter] = useState<ChatFilter>("all");
const [onlyFavorites, setOnlyFavorites] = useState(false);
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
const [tabTotal, setTabTotal] = useState<number | null>(null);
// 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。
const { drafts: roundtableTabDrafts, refresh: refreshRoundtableTabDrafts } = useRoundtableDrafts();
const { sessions: writingTabSessions, refresh: refreshWritingTabSessions } = useAIWritingSessions();
const { data: researchTabData } = useResearchSessions(100);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
useEffect(() => {
void refreshRoundtableTabDrafts();
void refreshWritingTabSessions();
}, [refreshRoundtableTabDrafts, refreshWritingTabSessions]);
// Debounce the search box so each keystroke doesn't fire a backend query.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
@@ -154,41 +72,26 @@ export default function ChatsPage({
// Reset to the first page whenever the filtered result set changes.
useEffect(() => {
setPage(1);
}, [debouncedSearch, chatFilter]);
// 切换 Tab 时回到第一页并清空旧 Tab 上报的计数。
useEffect(() => {
setPage(1);
setTabTotal(null);
}, [activeTab]);
const handleTabTotalChange = useCallback((total: number) => {
setTabTotal(total);
}, []);
}, [debouncedSearch, onlyFavorites]);
// Conversations are paginated on the backend: each page is one bounded
// query (title search + favorites filter included), so a long history is
// never fetched in one go. Pinned conversations come from their own small
// query and are surfaced in a dedicated section on the first page only,
// and dropped from the paginated rows so they don't repeat on every page.
const threadFilter = useMemo(
() =>
chatFilter === "favorites"
? { is_favorite: true }
: chatFilter === "pinned"
? { is_pinned: true }
: {},
[chatFilter],
const favoriteFilter = useMemo(
() => (onlyFavorites ? { is_favorite: true } : {}),
[onlyFavorites],
);
const pinnedFilter = useMemo(
() => ({ ...threadFilter, is_pinned: true }),
[threadFilter],
() => ({ ...favoriteFilter, is_pinned: true }),
[favoriteFilter],
);
const { data: pageData } = useThreadsPage({
page,
pageSize: PAGE_SIZE,
search: debouncedSearch,
metadata: threadFilter,
metadata: favoriteFilter,
});
const { data: pinnedData } = useThreadsPage({
page: 1,
@@ -198,28 +101,13 @@ export default function ChatsPage({
});
const { data: totalCount } = useThreadsCount({
search: debouncedSearch,
metadata: threadFilter,
});
const { data: allThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: {},
});
const { data: favoriteThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: { is_favorite: true },
});
const { data: pinnedThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: { is_pinned: true },
metadata: favoriteFilter,
});
const pinnedThreads = chatFilter === "pinned" ? [] : (pinnedData?.threads ?? []);
const pinnedThreads = pinnedData?.threads ?? [];
const pagedThreads = useMemo(
() =>
(pageData?.threads ?? []).filter(
(thread) => chatFilter === "pinned" || !isThreadPinned(thread),
),
[chatFilter, pageData?.threads],
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
[pageData?.threads],
);
const currentPage = page;
const hasMore = pageData?.hasMore ?? false;
@@ -234,67 +122,46 @@ export default function ChatsPage({
return (
<div
key={thread.thread_id}
className={cn(
"group/chat-row relative flex min-w-0 items-start gap-3 rounded-[10px] border bg-card p-3 transition-[border-color,box-shadow,background-color] duration-150 sm:items-center sm:p-3.5",
pinned
? "border-red-200 bg-gradient-to-r from-red-50/50 via-white to-white shadow-xs hover:border-red-300 hover:shadow-sm"
: "border-neutral-200/80 bg-white hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs",
)}
className="group/chat-row relative flex flex-col gap-2 border-b"
>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"mt-0.5 shrink-0 rounded-lg sm:mt-0",
favorited
? "bg-amber-50 text-amber-500 hover:bg-amber-100 hover:text-amber-600"
: "text-muted-foreground/45 hover:bg-muted hover:text-muted-foreground",
)}
title={favorited ? t.common.unfavorite : t.common.favorite}
aria-label={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star className={cn("size-4", favorited && "fill-current")} />
</Button>
<Link
href={pathOfThread(thread)}
className="min-w-0 flex-1 rounded-lg p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex min-w-0 items-center gap-2">
<Link href={pathOfThread(thread)} className="block p-4 pr-24">
<div className="flex items-center gap-1.5">
{pinned && (
<span className="inline-flex items-center gap-1 rounded border !border-red-200 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
<Pin className="size-3 rotate-45 text-red-600" />
置顶
</span>
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" />
)}
<span className="inline-flex items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<MessageSquare className="size-3 text-red-600" />
对话
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-neutral-900 transition-colors group-hover/chat-row:text-red-700 sm:text-base">
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</div>
{thread.updated_at && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground sm:hidden">
<Clock className="size-3" />
<div className="text-muted-foreground mt-2 text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</Link>
{thread.updated_at && (
<div className="hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
<Clock className="size-3" />
<span>{formatTimeAgo(thread.updated_at)}</span>
</div>
)}
<div className="flex shrink-0 items-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm:focus-within:opacity-100">
{/* Row actions — kept out of the Link so clicks don't navigate. */}
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100">
<Button
variant="ghost"
size="icon-sm"
size="icon"
className="size-8"
title={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star
className={cn(
"size-4",
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
title={pinned ? t.common.unpin : t.common.pin}
aria-label={pinned ? t.common.unpin : t.common.pin}
onClick={() => togglePin(thread.thread_id, !pinned)}
>
{pinned ? (
@@ -309,15 +176,6 @@ export default function ChatsPage({
};
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
// 头部计数:对话 Tab 用后端总数,其余 Tab 用组件回传的过滤后条数。
const displayTotal = activeTab === "chats" ? totalCount : tabTotal;
const activeTabConfig = HISTORY_TABS.find((tab) => tab.id === activeTab)!;
const historyTabCounts: Record<HistoryTab, number> = {
chats: allThreadsCount ?? 0,
roundtable: roundtableTabDrafts.length,
writing: writingTabSessions.length,
research: researchTabData?.total ?? 0,
};
return (
<WorkspaceContainer>
@@ -326,287 +184,99 @@ export default function ChatsPage({
compactChrome ? (
<div className="flex items-center gap-1">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</div>
) : null
}
/>
<WorkspaceBody>
<div className="flex size-full min-w-0 flex-col">
<div className="flex size-full flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="relative w-full max-w-5xl px-4 sm:px-6">
<div className="flex w-full flex-col items-center">
<div
id="primary-tabs-nav"
className="inline-flex rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
role="tablist"
aria-label="历史记录类型"
>
{HISTORY_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
const count = historyTabCounts[tab.id];
return (
<button
key={tab.id}
id={`history-tab-${tab.id}`}
type="button"
className={cn(
"relative flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
isActive
? "font-semibold text-red-700 shadow-xs"
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
)}
role="tab"
aria-selected={isActive}
aria-controls={`history-panel-${tab.id}`}
title={tab.description}
onClick={() => setActiveTab(tab.id)}
>
{isActive && (
<motion.div
layoutId="activeTabBadge"
className="absolute inset-0 rounded-xl border !border-red-200 bg-red-50/90 shadow-xs"
transition={{ type: "spring", stiffness: 450, damping: 35 }}
aria-hidden="true"
/>
)}
<span
className={cn(
"relative z-10 flex items-center gap-2",
isActive
? "font-semibold text-red-700"
: "text-neutral-600",
)}
>
<Icon className="size-4" aria-hidden="true" />
<span>{tab.label}</span>
</span>
{count > 0 && (
<span
className={cn(
"relative z-10 rounded-full px-1.5 py-[0.05rem] font-mono text-[11px] transition-colors",
isActive
? "bg-red-600 font-bold text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{count}
</span>
)}
</button>
);
})}
</div>
</div>
{activeTab === "chats" && (
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
variant="outline"
variant={onlyFavorites ? "outline" : "secondary"}
size="sm"
className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6"
onClick={() => setImportOpen(true)}
onClick={() => setOnlyFavorites(false)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
{t.chats.allChats}
</Button>
)}
<Button
variant={onlyFavorites ? "secondary" : "outline"}
size="sm"
onClick={() => setOnlyFavorites(true)}
>
<Star className="size-4" />
{t.chats.favorites}
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{totalCount != null && (
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums">
共 {totalCount} 条对话
</div>
)}
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full pb-8 pt-5 sm:pt-7">
<div className="mx-auto flex w-full max-w-5xl flex-col px-4 sm:px-6">
<section
id={`history-panel-${activeTab}`}
role="tabpanel"
aria-labelledby={`history-tab-${activeTab}`}
className="space-y-4 rounded-2xl border border-red-200 bg-white p-4 shadow-xs sm:p-5"
>
{activeTab !== "roundtable" && <div className="w-full space-y-3.5">
<div className="flex items-center justify-between gap-3">
{activeTab === "chats" ? (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0" role="group" aria-label="对话筛选">
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "all"
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700",
)}
aria-pressed={chatFilter === "all"}
onClick={() => setChatFilter("all")}
>
<span>全部</span>
{allThreadsCount != null && (
<span
className={cn(
"rounded-full px-1.5 py-px font-mono text-[11px]",
chatFilter === "all"
? "bg-red-700 text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{allThreadsCount}
</span>
)}
</button>
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30",
chatFilter === "favorites"
? "border-amber-300 bg-amber-50 font-semibold text-amber-700 shadow-xs"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900",
)}
aria-pressed={chatFilter === "favorites"}
onClick={() => setChatFilter("favorites")}
>
<Star className={cn("size-3.5", chatFilter === "favorites" ? "fill-amber-500 text-amber-500" : "text-neutral-400")} />
<span>收藏</span>
{favoriteThreadsCount != null && (
<span
className={cn(
"rounded-full px-1.5 py-px font-mono text-[11px]",
chatFilter === "favorites"
? "bg-amber-200 text-amber-900"
: "bg-neutral-100 text-neutral-500",
)}
>
{favoriteThreadsCount}
</span>
)}
</button>
{pinnedThreadsCount != null && pinnedThreadsCount > 0 && (
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "pinned"
? "border-red-200 bg-red-50 font-semibold text-red-700 shadow-xs"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-red-50/50 hover:text-red-700",
)}
aria-pressed={chatFilter === "pinned"}
onClick={() => setChatFilter("pinned")}
>
<Pin className="size-3.5 rotate-45 text-red-600" />
<span>置顶</span>
<span className="rounded-full bg-red-100 px-1.5 py-px font-mono text-[11px] font-medium text-red-800">
{pinnedThreadsCount}
</span>
</button>
)}
</div>
) : (
<div className="min-w-0 truncate text-sm font-medium text-neutral-500">
{activeTabConfig.description}
</div>
)}
<label className="relative min-w-0 flex-1 md:max-w-md">
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400"
aria-hidden="true"
/>
<span className="sr-only">
{activeTab === "chats"
? t.chats.searchChats
: TAB_SEARCH_PLACEHOLDER[activeTab]}
</span>
<input
type="search"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20"
placeholder={
activeTab === "chats"
? "搜索对话标题、关键词或标签…"
: TAB_SEARCH_PLACEHOLDER[activeTab]
}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30"
aria-label="清空搜索"
onClick={() => setSearch("")}
>
<X className="size-3.5" />
</button>
)}
</label>
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{currentPage === 1 && pinnedThreads.length > 0 && (
<>
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
{t.chats.pinnedSection}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
{displayTotal != null && (
<span className="font-medium">
共 <strong className="font-bold text-red-700">{displayTotal}</strong>{" "}
{TAB_TOTAL_UNIT[activeTab]}
</span>
)}
</div>
</div>}
{activeTab === "chats" && (
<div className="space-y-3 pt-5">
{currentPage === 1 && pinnedThreads.map(renderThread)}
{pagedThreads.map(renderThread)}
{isEmpty && (
<div className="text-muted-foreground py-16 text-center text-sm">
{chatFilter === "favorites"
? t.chats.noFavorites
: chatFilter === "pinned"
? "暂无置顶对话"
: t.chats.noChats}
</div>
)}
{(currentPage > 1 || hasMore) && (
<div className="flex items-center justify-center gap-3 border-t border-primary/10 pt-5">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
{t.chats.prevPage}
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
{totalPages != null
? `${currentPage} / ${totalPages}`
: currentPage}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
)}
{activeTab === "roundtable" && (
<RoundtableHistoryTab onTotalChange={handleTabTotalChange} />
)}
{activeTab === "writing" && (
<div className="pt-5">
<WritingHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
)}
{activeTab === "research" && (
<div className="pt-5">
<ResearchHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
)}
</section>
{pinnedThreads.map(renderThread)}
</>
)}
{pagedThreads.map(renderThread)}
{isEmpty && (
<div className="text-muted-foreground py-16 text-center text-sm">
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
</div>
)}
{(currentPage > 1 || hasMore) && (
<div className="flex items-center justify-center gap-3 py-6">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
{t.chats.prevPage}
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
{totalPages != null
? `${currentPage} / ${totalPages}`
: currentPage}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
</ScrollArea>
</main>
+10 -6
View File
@@ -48,7 +48,6 @@ import {
useThreadStream,
} from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
@@ -292,11 +291,16 @@ export default function IframeChatPage() {
threadMetadataAgentId,
]);
const writingArtifactPath = useMemo(
() =>
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
[selectedArtifact, thread.values.artifacts],
);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const handleSubmit = useCallback(
(
@@ -19,7 +19,7 @@ export default function LightAppIframeView() {
// 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、
// 把新主题色传给被嵌套应用(不依赖 <html> 的 dark class,避免时序读旧值)。
// 深色=dark-blue,浅蓝=blue,其余(红白)=red。
// 深色=dark-blue,绿色=green,浅蓝=blue,其余(红白)=red。
const themeColor: AppThemeColor =
theme === "dark"
? "dark-blue"
@@ -3,11 +3,6 @@ import { useState } from "react";
import { Navigate, useParams, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
KNOWLEDGE_NORMAL_VIEW_VALUE,
KnowledgeProductNav,
useKnowledgeBaseProductVersion,
} from "@/components/workspace/knowledge-product-nav";
import {
Dialog,
DialogContent,
@@ -75,9 +70,7 @@ export default function LlmWikiKnowledgePage({
const { noteId = "" } = useParams();
const [searchParams] = useSearchParams();
const runtime = useLlmWikiRuntime();
const productVersion = useKnowledgeBaseProductVersion();
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const explicitNormalView = searchParams.get("view") === KNOWLEDGE_NORMAL_VIEW_VALUE;
if (mode === "detail" && noteId && wikiSlug) {
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
@@ -87,14 +80,7 @@ export default function LlmWikiKnowledgePage({
return <RuntimeLoading />;
}
if (mode === "list" && productVersion.data === "v2" && !explicitNormalView) {
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
if (runtime.isError || runtime.data?.provider !== "weknora") {
if (mode === "list" || mode === "detail") {
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
if (mode === "graph") {
return <KnowledgeGraphPage />;
}
@@ -109,5 +95,5 @@ export default function LlmWikiKnowledgePage({
return <WeKnoraKnowledgeDetailPage />;
}
return <div className="flex size-full min-h-0 flex-col"><KnowledgeProductNav /><div className="min-h-0 flex-1"><WeKnoraKnowledgeWorkspace /></div></div>;
return <WeKnoraKnowledgeWorkspace />;
}
+10 -8
View File
@@ -16,7 +16,6 @@ import {
readHideHeaderParam,
readHideChromeParam,
} from "@/core/embed/embed-session";
import { isHostNestedSurfacePath } from "@/core/page-layout/host-nested-surface";
import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom";
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
@@ -92,20 +91,23 @@ function PageLayout() {
location.pathname.startsWith("/page/workspace/chats/")
);
}, [location.pathname, location.search]);
// 深链嵌套页(单智能体问答 / 多智能体会商 / 大屏绘制)只给宿主 iframe 套用,
// 路径上就不带全局 Header / PageSidebar,不靠改写共用 embed.session。
const hostNestedSurface = isHostNestedSurfacePath(location.pathname);
// 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页,
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
const forceChromeForDashboardAgent =
!forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent");
const pageSidebarAllowed =
chromeByPreference &&
(forceChromeForDashboardAgent || chromeByPreference) &&
!isChatsIframeWorkspacePath(location.pathname) &&
!embedActive &&
!chromeHidden &&
!hostNestedSurface;
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。
!chromeHidden;
const compactToolboxAllowed =
!chromeByPreference &&
!forceChromeForDashboardAgent &&
!embedActive &&
!chromeHidden &&
!hostNestedSurface &&
!isChatsIframeWorkspacePath(location.pathname);
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
+6 -21
View File
@@ -1,8 +1,7 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { RouteBaseProvider } from "@/core/route-base";
@@ -80,24 +79,6 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
* Strategy's legacy `/chats/:thread_id` route shares ChatPage's header.
* Unlike WorkspaceRoutes it did not render a WorkspaceSidebar, leaving the
* header's expand/collapse control with no visible panel to operate.
*/
function StrategyChatThreadLayout() {
return (
<div className="flex size-full min-h-0 overflow-hidden">
<WorkspaceSidebar compactChrome />
<SidebarInset className="min-w-0">
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</SidebarInset>
</div>
);
}
export default function StrategyRoutes() {
const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
@@ -361,7 +342,11 @@ export default function StrategyRoutes() {
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={<StrategyChatThreadLayout />}
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
<Route path="agents" element={<AgentsPage />} />
+3 -102
View File
@@ -1,8 +1,7 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { getAPIClient } from '@/core/api';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import {
resolveChatsIframeEntryRedirect,
@@ -26,11 +25,9 @@ import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
import { RouteBaseProvider } from '@/core/route-base';
import { agentRefOfThread } from '@/core/threads/utils';
import ChatsPage from './ChatsPage';
import ChatPage from './ChatPage';
import ArtifactLibraryPage from './ArtifactLibraryPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
@@ -42,7 +39,6 @@ import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import AssistantKnowledgePage from './AssistantKnowledgePage';
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
@@ -97,7 +93,6 @@ function shouldShowCompactTopActions(pathname: string): boolean {
pathname === '/page/workspace/agents' ||
pathname === '/page/workspace/skills' ||
pathname === '/page/workspace/memory' ||
pathname === '/page/workspace/library' ||
pathname === '/page/workspace/knowledge' ||
pathname.startsWith('/page/workspace/knowledge/')
);
@@ -185,11 +180,7 @@ export function WorkspaceLayout({
'--header-height': headerHidden ? '0rem' : '4rem',
} as CSSProperties
}
// Agent and general-chat routes share this layout component. Keep their
// sidebar state separate: otherwise React preserves the collapsed agent
// state when a history item switches to `/chats/:thread_id`, while the
// new page's header button is meant to control GeneralQaSidebar.
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? 'general' : 'workspace'}`}
key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={
forceWorkspaceSidebarCollapsed
@@ -350,80 +341,12 @@ function ChatThreadEntryRoute({
return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<AgentAwareChatThreadContent />
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
);
}
/**
* Older bookmarks and a few historic list entries use the generic
* ``/chats/:thread_id`` path even for an agent-owned conversation. Recover the
* owning agent from the persisted thread record before rendering so those
* links keep the agent workspace (agent list and detail panel) instead of
* falling back to the general Q&A page. Task workspace threads intentionally
* remain on their explicit task routes because their surrounding layout is
* part of the task context.
*/
function AgentAwareChatThreadContent() {
const { thread_id: threadId } = useParams<{ thread_id: string }>();
const location = useLocation();
const [agentId, setAgentId] = useState<string | null>(null);
const [resolved, setResolved] = useState(false);
useEffect(() => {
let cancelled = false;
setAgentId(null);
setResolved(false);
if (!threadId) {
setResolved(true);
return () => {
cancelled = true;
};
}
void getAPIClient()
.threads.get(threadId)
.then((thread) => {
if (cancelled) return;
const record = thread as {
context?: { agent_id?: string; agent_name?: string } | null;
metadata?: Record<string, unknown> | null;
};
// Task-bound threads need their task host to preserve the purpose gate,
// action partitioning, and other task-specific controls.
const isTaskThread = typeof record.metadata?.taskId === 'string';
setAgentId(isTaskThread ? null : agentRefOfThread(record) ?? null);
})
.catch(() => {
if (!cancelled) setAgentId(null);
})
.finally(() => {
if (!cancelled) setResolved(true);
});
return () => {
cancelled = true;
};
}, [threadId]);
if (!resolved) {
return <div className="size-full bg-background" aria-busy="true" />;
}
if (threadId && agentId) {
return (
<Navigate
replace
to={`/page/workspace/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
/>
);
}
return <ChatPage />;
}
/**
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
@@ -521,20 +444,6 @@ export default function WorkspaceRoutes({
path="chats/:thread_id"
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="library"
element={
<WorkspaceLayout
{...layoutProps}
workspaceSidebarHidden={
layoutProps.workspaceSidebarHidden ||
!layoutProps.pageSidebarHidden
}
>
<ArtifactLibraryPage />
</WorkspaceLayout>
}
/>
<Route
path="chats_iframe/new"
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
@@ -770,14 +679,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/assistant"
element={
<WorkspaceLayout {...layoutProps}>
<AssistantKnowledgePage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/wiki/view"
element={
@@ -1,209 +0,0 @@
"use client";
/**
* 「对话历史」页 —— 深度研究 Tab。
*
* 数据来自 deep-research 会话列表(useResearchSessions)。点击跳转深度研究页并
* 带 ``?session=<id>``,由 DeepResearchWorkbench 挂载时读取该参数直接选中并
* 打开这条研究记录。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Search } from "lucide-react";
import { toast } from "sonner";
import {
useResearchActions,
useResearchSessions,
type DeepResearchSession,
} from "@/strategy-components/api/deep-research";
import { useReportStructures } from "@/strategy-components/api/report-structures";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { desensitize } from "@/lib/desensitize";
import {
DeleteIcon,
HistoryRenameDialog,
HistoryRow,
HistoryRowIconButton,
HistoryStatusPill,
HistoryTypeBadge,
RenameIcon,
} from "./history-tab-shared";
const DEEP_RESEARCH_PATH = "/page/workspace/deep-research";
/** 状态文案与配色对齐 DeepResearchWorkbench 的研究记录侧栏。 */
const RESEARCH_STATUS: Record<
DeepResearchSession["status"],
{ label: string; className: string }
> = {
draft: { label: "待开始", className: "text-muted-foreground" },
collecting: { label: "收集中", className: "text-primary" },
awaiting_report: { label: "待撰写", className: "text-amber-500" },
running: { label: "进行中", className: "text-primary" },
awaiting_input: { label: "等待确认", className: "text-amber-500" },
completed: { label: "已完成", className: "text-emerald-600" },
failed: { label: "失败", className: "text-rose-500" },
cancelled: { label: "已取消", className: "text-muted-foreground" },
};
/**
* 深度研究会话把选中的报告模板快照为 custom_outline;用结构库里的内容反查标题。
* 内容不再存在或用户输入了自定义大纲时,明确展示为「自定义报告结构」,避免冒充模板名。
*/
function reportTemplateLabel(
session: DeepResearchSession,
structures: { title: string; content: string }[],
): string {
const config = session.config ?? {};
const outline = typeof config.custom_outline === "string"
? config.custom_outline.trim()
: typeof config.customOutline === "string"
? config.customOutline.trim()
: "";
if (!outline) return "默认报告结构";
return structures.find((structure) => structure.content.trim() === outline)?.title ?? "自定义报告结构";
}
export function ResearchHistoryTab({
query,
onTotalChange,
}: {
query: string;
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { data, isLoading } = useResearchSessions(100);
const structuresQuery = useReportStructures();
const actions = useResearchActions();
const [renameId, setRenameId] = useState<string | null>(null);
const [renameTitle, setRenameTitle] = useState("");
const sessions = data?.items ?? [];
const keyword = query.trim().toLowerCase();
const filtered = keyword
? sessions.filter((session) =>
(session.title || session.query).toLowerCase().includes(keyword),
)
: sessions;
useEffect(() => {
onTotalChange?.(filtered.length);
}, [filtered.length, onTotalChange]);
const openSession = (session: DeepResearchSession) => {
navigate(`${DEEP_RESEARCH_PATH}?session=${encodeURIComponent(session.id)}`);
};
const handleRename = async (id: string, title: string) => {
try {
await actions.updateTitle.mutateAsync({ id, title });
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
}
};
const handleDelete = async (session: DeepResearchSession) => {
if (!window.confirm("确定删除这条研究记录吗?此操作不可恢复。")) return;
try {
await actions.delete.mutateAsync(session.id);
toast.success("已删除研究记录");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败,请重试");
}
};
if (isLoading && sessions.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的研究记录" : "暂无研究记录"}
</div>
);
}
return (
<>
<div>
{filtered.map((session) => {
const status = RESEARCH_STATUS[session.status] ?? {
label: session.status,
className: "text-muted-foreground",
};
const timestamp = session.updatedAt ?? session.createdAt ?? null;
const template = reportTemplateLabel(session, structuresQuery.data ?? []);
return (
<HistoryRow
key={session.id}
badge={<HistoryTypeBadge icon={Search} label="深度研究" />}
title={desensitize(session.title || session.query || "未命名研究")}
meta={
<>
<HistoryStatusPill
label={status.label}
className={status.className}
/>
{session.sourceCount > 0 && (
<>
<span className="text-muted-foreground/60">·</span>
<span>{session.sourceCount} 条来源</span>
</>
)}
<span className="text-muted-foreground/60">·</span>
<span className="max-w-56 truncate" title={`模板:${template}`}>
模板:{template}
</span>
{timestamp && (
<>
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(timestamp)}</span>
</>
)}
</>
}
onOpen={() => openSession(session)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title || session.query || "");
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名研究"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
</>
);
}
@@ -1,466 +0,0 @@
"use client";
/**
* 「对话历史」页 —— 多智能体会商 Tab。
*
* 卡片与筛选区按最新「智能工作区管理 (1)」的 ConversationRow /
* FilterAndSearchBar 适配。会商步骤、状态、模式、互动数和模型都来自实际草稿快照。
*/
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ArrowUpDown,
Clock,
Cpu,
ExternalLink,
Search,
Tag,
Trash2,
Users2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import {
getDraft,
type DraftMeta,
type DraftRecord,
} from "@/roundtable-planning/api/drafts";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { desensitize } from "@/lib/desensitize";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
type SortType = "updated_desc" | "updated_asc" | "messages_desc";
interface RoundtableItem {
draft: DraftMeta;
record?: DraftRecord;
status: ReturnType<typeof progressOf>;
businessChainName: string;
messageCount: number;
model?: string;
tags: string[];
}
function progressOf(draft: DraftMeta): {
label: string;
stepLabel: string;
tone: "completed" | "active" | "waiting" | "failed" | "cancelled";
} {
const stepLabel = `第 ${draft.furthestStep} 步`;
if (draft.status === "done" || (!draft.status && draft.furthestStep === 3)) {
return { label: "已完成", tone: "completed", stepLabel };
}
if (draft.status === "error") {
return { label: "执行失败", tone: "failed", stepLabel: `停在${stepLabel}` };
}
if (draft.status === "cancelled") {
return { label: "已取消", tone: "cancelled", stepLabel: `停在${stepLabel}` };
}
if (draft.status === "awaiting_input") {
return { label: "等待确认", tone: "waiting", stepLabel: `当前${stepLabel}` };
}
return { label: "进行中", tone: "active", stepLabel: `当前${stepLabel}` };
}
function modeOf(record?: DraftRecord): string {
switch (record?.step2?.orchestrationMode) {
case "chain":
return "业务链会商";
case "dag":
return "DAG 编排";
default:
return "自由模式";
}
}
function businessChainNameOf(draft: DraftMeta, record?: DraftRecord): string {
return record?.step2?.chain?.title ?? draft.chainTitle ?? modeOf(record);
}
function interactionCount(record?: DraftRecord): number {
if (!record) return 0;
return (
(record.step1?.interactiveMessages?.length ?? 0) +
(record.step2?.step2RoundtableDialogues?.length ?? 0) +
(record.step3?.summaryReport?.dialogues?.length ?? 0) +
(record.step3?.reportDialogues?.length ?? 0) +
(record.step3?.dashboardDialogues?.length ?? 0)
);
}
function modelOf(record?: DraftRecord): string | undefined {
return record?.step3?.summaryReport?.model ?? record?.step3?.model;
}
function tagsOf(draft: DraftMeta, record?: DraftRecord): string[] {
const chain = record?.step2?.chain?.title ?? draft.chainTitle;
return chain ? [chain] : [];
}
function timestamp(value: string): number {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function renderHighlightedTitle(text: string, query: string) {
if (!query.trim()) return text;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const parts = text.split(new RegExp(`(${escaped})`, "gi"));
return parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={index} className="rounded-xs bg-red-100 px-0.5 font-semibold text-red-900">
{part}
</mark>
) : (
part
),
);
}
function RoundtableHistoryRow({
item,
searchQuery,
onDelete,
onOpen,
}: {
item: RoundtableItem;
searchQuery: string;
onDelete: () => void;
onOpen: () => void;
}) {
const { draft, status } = item;
const statusClass = {
completed: "border-emerald-200/80 bg-emerald-50 text-emerald-700",
active: "border-red-200/80 bg-red-50 text-red-600",
waiting: "border-amber-200/80 bg-amber-50 text-amber-700",
failed: "border-rose-200/80 bg-rose-50 text-rose-600",
cancelled: "border-neutral-200/80 bg-neutral-50 text-neutral-500",
}[status.tone];
const statusDotClass = {
completed: "bg-emerald-500",
active: "bg-red-500",
waiting: "bg-amber-500",
failed: "bg-rose-500",
cancelled: "bg-neutral-400",
}[status.tone];
return (
<div
id={`conversation-item-${draft.id}`}
onClick={(event) => {
if ((event.target as HTMLElement).closest("button, input")) return;
onOpen();
}}
className="group relative flex cursor-pointer items-start justify-between rounded-xl border border-neutral-200/80 bg-white p-4 transition-all duration-150 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs sm:items-center"
>
<div className="flex min-w-0 flex-1 items-start gap-3.5 pr-4 sm:items-center">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border !border-red-200 bg-red-50/90 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<span className="relative flex size-1.5">
{status.tone === "active" && (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
)}
<span className="relative inline-flex size-1.5 rounded-full bg-red-600" />
</span>
<Users2 className="size-3 text-red-600" />
会商
</span>
<h3 className="truncate text-sm font-semibold text-neutral-900 transition-colors group-hover:text-red-700">
{renderHighlightedTitle(desensitize(draft.title || "未命名会商"), searchQuery)}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2 pt-0.5 text-[11px] text-neutral-400">
<span className={`inline-flex items-center gap-1 rounded border !border-red-200 px-1.5 py-0.5 text-[10px] font-medium ${statusClass}`}>
<span className={`size-1.5 rounded-full ${statusDotClass}`} />
{status.label}
</span>
<span className="inline-flex items-center rounded border border-neutral-200/60 bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-600">
{status.stepLabel}
</span>
<span className="inline-flex items-center gap-1 rounded border border-neutral-200/40 bg-neutral-50 px-1.5 py-0.5 text-[10px] text-neutral-500">
<Cpu className="size-2.5 text-neutral-400" />
{item.businessChainName}
</span>
<span className="inline-flex items-center gap-1 text-neutral-400">
<Clock className="size-3 text-neutral-400" />
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="hidden items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 sm:flex">
<button
id={`delete-btn-${draft.id}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className="rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
title="删除会商"
aria-label="删除会商"
>
<Trash2 className="size-3.5" />
</button>
<button
id={`open-preview-btn-${draft.id}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-700"
title="打开会商详情"
aria-label="打开会商详情"
>
<ExternalLink className="size-3.5" />
</button>
</div>
</div>
</div>
);
}
export function RoundtableHistoryTab({
onTotalChange,
}: {
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { drafts, loading, refresh, remove } = useRoundtableDrafts();
const [records, setRecords] = useState<Record<string, DraftRecord>>({});
const [searchQuery, setSearchQuery] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [sortType, setSortType] = useState<SortType>("updated_desc");
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
let cancelled = false;
if (drafts.length === 0) {
setRecords({});
return () => {
cancelled = true;
};
}
void Promise.all(
drafts.map(async (draft) => {
try {
return await getDraft(draft.id);
} catch {
return null;
}
}),
).then((fullDrafts) => {
if (cancelled) return;
setRecords(
Object.fromEntries(
fullDrafts
.filter((record): record is DraftRecord => record !== null)
.map((record) => [record.id, record]),
),
);
});
return () => {
cancelled = true;
};
}, [drafts]);
const allItems = useMemo<RoundtableItem[]>(
() =>
drafts.map((draft) => {
const record = records[draft.id];
return {
draft,
record,
status: progressOf(draft),
businessChainName: businessChainNameOf(draft, record),
messageCount: interactionCount(record),
model: modelOf(record),
tags: tagsOf(draft, record),
};
}),
[drafts, records],
);
const availableTags = useMemo(
() => [...new Set(allItems.flatMap((item) => item.tags))],
[allItems],
);
const filteredItems = useMemo(() => {
const keyword = searchQuery.trim().toLowerCase();
return allItems
.filter((item) => {
if (selectedTag && !item.tags.includes(selectedTag)) return false;
return !keyword || (item.draft.title || "未命名会商").toLowerCase().includes(keyword);
})
.sort((left, right) => {
if (sortType === "messages_desc") return right.messageCount - left.messageCount;
const delta = timestamp(right.draft.updatedAt) - timestamp(left.draft.updatedAt);
return sortType === "updated_desc" ? delta : -delta;
});
}, [allItems, searchQuery, selectedTag, sortType]);
useEffect(() => {
onTotalChange?.(filteredItems.length);
}, [filteredItems.length, onTotalChange]);
const openDraft = (draft: DraftMeta) => {
sessionStorage.setItem("roundtable:pending-draft", draft.id);
navigate(ROUNDTABLE_PATH);
};
const deleteDraft = async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
toast.success("已删除会商记录");
} catch {
toast.error("删除失败,请重试");
}
};
return (
<div className="w-full space-y-4">
<div className="w-full space-y-3.5">
<div className="flex flex-col items-stretch justify-between gap-3 md:flex-row md:items-center">
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0">
<button
id="filter-all-btn"
type="button"
onClick={() => setSelectedTag(null)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all ${
!selectedTag
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700"
}`}
>
<span>全部</span>
<span className={`rounded-full px-1.5 py-0.2 font-mono text-[11px] ${!selectedTag ? "bg-red-700 font-semibold text-white" : "bg-neutral-100 text-neutral-500"}`}>
{allItems.length}
</span>
</button>
{selectedTag && (
<div className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs">
<Tag className="size-3 text-red-100" />
<span>{selectedTag}</span>
<button
type="button"
onClick={() => setSelectedTag(null)}
className="ml-1 hover:text-red-200"
title="清除标签筛选"
aria-label="清除标签筛选"
>
<X className="size-3" />
</button>
</div>
)}
</div>
<div className="relative flex-1 md:max-w-md">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<input
id="conversation-search-input"
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索会商记录…"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20"
/>
{searchQuery ? (
<button
id="clear-search-query-btn"
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600"
aria-label="清空搜索"
>
<X className="size-3.5" />
</button>
) : (
<div className="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded bg-neutral-200/60 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 sm:flex">
⌘K
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
<div className="flex items-center gap-3 overflow-x-auto">
<span className="whitespace-nowrap font-medium text-neutral-500">
共 <strong className="font-bold text-red-600">{filteredItems.length}</strong> 条会商记录
{searchQuery && <span className="ml-1 text-neutral-400">(匹配 “{searchQuery}”)</span>}
</span>
{availableTags.length > 0 && (
<div className="hidden items-center gap-1.5 text-xs lg:flex">
<span className="text-neutral-400">快捷标签:</span>
{availableTags.slice(0, 4).map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`rounded-md px-2 py-0.5 transition-colors ${
selectedTag === tag
? "bg-red-600 font-medium text-white shadow-2xs"
: "border !border-red-200 bg-red-50 text-red-700 hover:bg-red-100 hover:text-red-800"
}`}
>
#{tag}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2 py-1 shadow-2xs">
<ArrowUpDown className="size-3 text-neutral-400" />
<select
id="sort-select-dropdown"
value={sortType}
onChange={(event) => setSortType(event.target.value as SortType)}
className="cursor-pointer bg-transparent text-xs text-neutral-700 focus:outline-hidden"
>
<option value="updated_desc">最近活跃优先</option>
<option value="updated_asc">最早创建优先</option>
<option value="messages_desc">消息数量最多</option>
</select>
</div>
</div>
</div>
</div>
{loading && allItems.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">加载中…</div>
) : filteredItems.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{searchQuery || selectedTag ? "没有匹配的会商记录" : "暂无会商记录"}
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<RoundtableHistoryRow
key={item.draft.id}
item={item}
searchQuery={searchQuery}
onDelete={() => void deleteDraft(item.draft)}
onOpen={() => openDraft(item.draft)}
/>
))}
</div>
)}
</div>
);
}
@@ -1,154 +0,0 @@
"use client";
/**
* 「对话历史」页 —— AI写作 Tab。
*
* 数据与原侧栏「最近写作」列表同源(useAIWritingSessions);点击行为一致:
* 暂存 sessionStorage「ai-writing:pending-session」后跳转 AI 写作页,由
* AIWritingPanel 挂载时消费并打开该会话。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { FilePenLine } from "lucide-react";
import { toast } from "sonner";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
import { desensitize } from "@/lib/desensitize";
import {
DeleteIcon,
HistoryRenameDialog,
HistoryRow,
HistoryRowIconButton,
HistoryStatusPill,
HistoryTypeBadge,
RenameIcon,
} from "./history-tab-shared";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
export function WritingHistoryTab({
query,
onTotalChange,
}: {
query: string;
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { sessions, loading, refresh, rename, remove } = useAIWritingSessions();
const [renameId, setRenameId] = useState<string | null>(null);
const [renameTitle, setRenameTitle] = useState("");
useEffect(() => {
void refresh();
}, [refresh]);
const keyword = query.trim().toLowerCase();
const filtered = keyword
? sessions.filter((session) => session.title.toLowerCase().includes(keyword))
: sessions;
useEffect(() => {
onTotalChange?.(filtered.length);
}, [filtered.length, onTotalChange]);
const openSession = (session: AIWritingSessionSummary) => {
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
navigate(AI_WRITING_PATH);
};
const handleRename = async (id: string, title: string) => {
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
}
};
const handleDelete = async (session: AIWritingSessionSummary) => {
if (!window.confirm("确定删除这条写作记录吗?此操作不可恢复。")) return;
try {
await remove(session.id);
toast.success("已删除写作记录");
} catch {
toast.error("删除失败,请重试");
}
};
if (loading && sessions.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的写作记录" : "暂无写作记录"}
</div>
);
}
return (
<>
<div>
{filtered.map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<HistoryRow
key={session.id}
badge={<HistoryTypeBadge icon={FilePenLine} label="AI写作" />}
title={desensitize(session.title)}
meta={
<>
<HistoryStatusPill label={label} className={color} />
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(session.updated_at)}</span>
</>
}
onOpen={() => openSession(session)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title);
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名写作"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
</>
);
}
@@ -1,181 +0,0 @@
"use client";
/**
* 「对话历史」页(/page/workspace/chats)业务历史 Tab(多智能体会商 / AI写作 /
* 深度研究)的共享小组件。行样式与「对话」Tab 保持一致:标题行 + 次行元信息 +
* 悬浮操作按钮,让四个 Tab 在同一页里观感统一。
*/
import { useEffect, useState, type ReactNode } from "react";
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
/** 次行状态小标签(对齐侧栏列表的状态药丸配色)。 */
export function HistoryStatusPill({
label,
className,
}: {
label: string;
className?: string;
}) {
return (
<span className={cn("text-[10px] font-medium", className)}>{label}</span>
);
}
/** 历史卡片左上角的类型标签,与对话卡片的「对话」标签保持同一视觉层级。 */
export function HistoryTypeBadge({
icon: Icon,
label,
}: {
icon: LucideIcon;
label: string;
}) {
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<Icon className="size-3 text-red-600" />
{label}
</span>
);
}
/**
* 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转
* 对应工作台,由页面挂载时消费),操作按钮经 children 传入。
*/
export function HistoryRow({
badge,
title,
meta,
onOpen,
children,
}: {
badge?: ReactNode;
title: ReactNode;
meta: ReactNode;
onOpen: () => void;
children?: ReactNode;
}) {
return (
<div className="group/chat-row relative mb-3 flex min-w-0 rounded-[10px] border border-neutral-200/80 bg-white transition-[border-color,box-shadow,background-color] duration-150 last:mb-0 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs">
<button
type="button"
onClick={onOpen}
className="block w-full p-3 pr-28 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:p-3.5 sm:pr-32"
>
<div className="flex min-w-0 items-center gap-2">
{badge}
<span className="min-w-0 flex-1 truncate text-sm font-semibold transition-colors group-hover/chat-row:text-red-700 sm:text-base">
{title}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{meta}
</div>
</button>
{/* Row actions — kept out of the open button so clicks don't trigger it. */}
<div className="absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-1 rounded-lg bg-white/90 opacity-100 shadow-sm backdrop-blur-sm transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm:focus-within:opacity-100">
{children}
</div>
</div>
);
}
/** 重命名 / 删除图标操作按钮(与「对话」Tab 的收藏/置顶按钮同规格)。 */
export function HistoryRowIconButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<Button
variant="ghost"
size="icon"
className="size-8"
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</Button>
);
}
export function RenameIcon() {
return <Pencil className="text-muted-foreground size-4" />;
}
export function DeleteIcon() {
return <Trash2 className="text-muted-foreground size-4" />;
}
/**
* 自包含重命名弹窗:打开时用 initialTitle 初始化输入框,确认时回调新标题。
* 三个 Tab 的重命名接口都是「(id, title) → Promise」,由调用方闭包绑定 id。
*/
export function HistoryRenameDialog({
open,
initialTitle,
dialogTitle,
onCancel,
onConfirm,
}: {
open: boolean;
initialTitle: string;
dialogTitle: string;
onCancel: () => void;
onConfirm: (title: string) => void;
}) {
const [value, setValue] = useState(initialTitle);
useEffect(() => {
if (open) setValue(initialTitle);
}, [open, initialTitle]);
return (
<Dialog open={open} onOpenChange={(o) => (o ? undefined : onCancel())}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
if (value.trim()) onConfirm(value.trim());
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button disabled={!value.trim()} onClick={() => onConfirm(value.trim())}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -20,7 +20,6 @@
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk";
import ReactMarkdown from "react-markdown";
import { Select as TSelect } from "tdesign-react";
@@ -445,12 +444,7 @@ export default function DeepResearchWorkbench() {
}
function DeepResearchWorkbenchInner() {
// 「?session=<id>」深链:对话历史页「深度研究」Tab 等外部入口直接定位到某条
// 研究记录。仅作初始选中(首帧读取一次);之后的切换仍走页内状态,不回写 URL。
const [searchParams] = useSearchParams();
const [selectedId, setSelectedId] = useState<string | null>(() =>
searchParams.get("session"),
);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [mode, setMode] = useState<ResearchMode>("basic");
const [generateImages, setGenerateImages] = useState(false);
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS);
@@ -155,12 +155,12 @@ export function ResearchSandboxPanel({
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out dark:bg-[#0b1426]",
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!open && "opacity-0",
)}
id="artifacts"
>
<div className="relative h-full bg-[#fffdfa] p-4 dark:bg-[#0b1426]">
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId && stubMessages.length > 0 ? (
<>
{documentRewriteActive && onCancelDocumentRewrite && (
@@ -114,7 +114,7 @@ const outlineMarkdownClassName =
"[&_p]:my-2.5 [&_ul]:my-2.5 [&_ol]:my-2.5 [&_li]:my-1 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_strong]:font-semibold [&_strong]:text-neutral-900 dark:[&_strong]:text-slate-100 " +
"[&_blockquote]:my-3 [&_blockquote]:border-l-4 [&_blockquote]:border-rose-200 [&_blockquote]:pl-4 [&_blockquote]:text-neutral-500 dark:[&_blockquote]:border-[#2b8ffe]/45 dark:[&_blockquote]:text-slate-400 " +
"[&_blockquote]:my-3 [&_blockquote]:border-l-4 [&_blockquote]:border-rose-200 [&_blockquote]:pl-4 [&_blockquote]:text-neutral-500 dark:[&_blockquote]:border-rose-900/50 dark:[&_blockquote]:text-slate-400 " +
"[&_code]:rounded [&_code]:border [&_code]:bg-neutral-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs dark:[&_code]:border-slate-700 dark:[&_code]:bg-slate-800 " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-neutral-50 [&_pre]:p-4 dark:[&_pre]:border-slate-700 dark:[&_pre]:bg-slate-800 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
@@ -309,17 +309,17 @@ export function WorkflowConfigDialog({
overlayClassName="bg-slate-900/60 backdrop-blur-xs"
className={
!detail
? "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-3xl border border-slate-200/80 bg-white p-0 shadow-2xl dark:border-[#263b5c] dark:bg-[#0f172a] sm:max-w-6xl"
: "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-2xl border border-neutral-200/90 bg-white p-0 shadow-2xl shadow-neutral-950/20 dark:border-[#263b5c] dark:bg-[#0f172a] sm:max-w-6xl"
? "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-3xl border border-slate-200/80 bg-white p-0 shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:max-w-6xl"
: "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-2xl border border-neutral-200/90 bg-white p-0 shadow-2xl shadow-neutral-950/20 dark:border-slate-800 dark:bg-slate-900 sm:max-w-6xl"
}
>
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题 + 返回结构库 */}
{!detail ? (
<>
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-slate-200/80 bg-white px-6 py-5 dark:border-[#263b5c]/80 dark:bg-[#0f172a]">
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-slate-200/80 bg-white px-6 py-5 dark:border-slate-800/80 dark:bg-slate-900">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-full bg-[#F5222D] dark:bg-[#4096ff]" aria-hidden />
<div className="h-2.5 w-2.5 rounded-full bg-[#F5222D]" aria-hidden />
<h2 className="truncate text-xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
工作流配置 · 报告结构
</h2>
@@ -331,7 +331,7 @@ export function WorkflowConfigDialog({
<Button
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 rounded-xl text-slate-400 hover:bg-slate-100 hover:text-[#CF1322] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
className="h-10 w-10 shrink-0 rounded-xl text-slate-400 hover:bg-slate-100 hover:text-[#CF1322] dark:hover:bg-slate-800 dark:hover:text-slate-200"
aria-label="关闭"
onClick={() => onOpenChange(false)}
>
@@ -339,7 +339,7 @@ export function WorkflowConfigDialog({
</Button>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200/80 bg-white px-6 py-3.5 dark:border-[#263b5c]/70 dark:bg-[#0f172a]">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200/80 bg-white px-6 py-3.5 dark:border-slate-800/60 dark:bg-slate-900">
<div className="flex min-w-[280px] flex-1 flex-wrap items-center gap-2.5">
<div className="relative min-w-[200px] max-w-xs flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" />
@@ -347,14 +347,14 @@ export function WorkflowConfigDialog({
value={titleFilter}
onChange={(event) => setTitleFilter(event.target.value)}
placeholder="按标题或检索词筛选..."
className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-100 dark:focus-visible:border-[#2b8ffe] dark:focus-visible:ring-[#2b8ffe]/25 sm:text-sm"
className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 sm:text-sm"
/>
{titleFilter && (
<button
type="button"
aria-label="清空筛选"
onClick={() => setTitleFilter("")}
className="absolute top-1/2 right-2.5 -translate-y-1/2 text-slate-400 hover:text-[#CF1322] dark:hover:text-[#9bc7ff]"
className="absolute top-1/2 right-2.5 -translate-y-1/2 text-slate-400 hover:text-[#CF1322] dark:hover:text-slate-200"
>
<X className="h-3.5 w-3.5" />
</button>
@@ -366,7 +366,7 @@ export function WorkflowConfigDialog({
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as ReportStructureStatusFilter)}
aria-label="启用状态筛选"
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:focus:border-[#2b8ffe] dark:focus:ring-[#2b8ffe]/25 sm:text-sm"
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 sm:text-sm"
>
<option value="all">全部状态</option>
<option value="enabled">已启用</option>
@@ -380,7 +380,7 @@ export function WorkflowConfigDialog({
value={modeFilter}
onChange={(event) => setModeFilter(event.target.value as "all" | ReportStructureMode)}
aria-label="大纲类型筛选"
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:focus:border-[#2b8ffe] dark:focus:ring-[#2b8ffe]/25 sm:text-sm"
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 sm:text-sm"
>
<option value="all">全部大纲</option>
<option value="adaptive">弹性大纲</option>
@@ -391,11 +391,11 @@ export function WorkflowConfigDialog({
</div>
<div className="flex shrink-0 items-center gap-2.5">
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300">
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
{visible.length} 套
</span>
<Button
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] sm:text-sm"
onClick={() => {
onOpenChange(false);
const base = location.pathname.replace(/\/+$/, "");
@@ -409,9 +409,9 @@ export function WorkflowConfigDialog({
</div>
</>
) : (
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-neutral-100 bg-white px-6 py-4 dark:border-[#263b5c] dark:bg-[#0f172a]">
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-neutral-100 bg-white px-6 py-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex min-w-0 items-center gap-3.5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-b from-[#b91c1c] to-[#991b1b] text-white shadow-xs shadow-rose-900/20 dark:from-[#2b8ffe] dark:to-[#0958d9] dark:shadow-blue-950/30">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-b from-[#b91c1c] to-[#991b1b] text-white shadow-xs shadow-rose-900/20">
<FileText className="h-5 w-5 text-white" strokeWidth={1.9} aria-hidden />
</div>
<div className="min-w-0">
@@ -420,8 +420,8 @@ export function WorkflowConfigDialog({
{detail.title}
</h2>
{detail.id === appliedStructureId && (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-rose-200/70 bg-rose-50 px-2.5 py-0.5 text-xs font-medium text-rose-700 dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600 dark:bg-[#69b1ff]" aria-hidden />
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-rose-200/70 bg-rose-50 px-2.5 py-0.5 text-xs font-medium text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600" aria-hidden />
当前使用
</span>
)}
@@ -440,7 +440,7 @@ export function WorkflowConfigDialog({
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3.5 py-1.5 text-xs font-medium text-neutral-700 shadow-2xs transition-all duration-150 hover:border-neutral-300 hover:bg-neutral-50 active:bg-neutral-100 dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:hover:bg-[#1c2d4a]"
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3.5 py-1.5 text-xs font-medium text-neutral-700 shadow-2xs transition-all duration-150 hover:border-neutral-300 hover:bg-neutral-50 active:bg-neutral-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={handleBackToList}
>
返回结构库
@@ -448,7 +448,7 @@ export function WorkflowConfigDialog({
<button
type="button"
aria-label="关闭弹窗"
className="rounded-lg p-2 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
className="rounded-lg p-2 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
@@ -460,7 +460,7 @@ export function WorkflowConfigDialog({
{/* body */}
{!detail ? (
<>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/60 dark:bg-[#0b1426]">
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/60 dark:bg-slate-900/40">
<div className="p-6">
{listQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-slate-500 dark:text-slate-400">
@@ -488,7 +488,7 @@ export function WorkflowConfigDialog({
setStatusFilter("all");
setModeFilter("all");
}}
className="inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3.5 py-1.5 text-xs font-medium text-[#CF1322] transition-colors hover:bg-slate-200 dark:bg-[#17345c] dark:text-[#9bc7ff] dark:hover:bg-[#1d467a]"
className="inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3.5 py-1.5 text-xs font-medium text-[#CF1322] transition-colors hover:bg-slate-200 dark:bg-rose-950/50 dark:text-[#FA9196]"
>
<RotateCcw className="h-3.5 w-3.5" />
重置所有筛选
@@ -528,12 +528,12 @@ export function WorkflowConfigDialog({
</div>
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200/80 bg-white px-6 py-3.5 text-xs dark:border-[#263b5c] dark:bg-[#0f172a]">
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200/80 bg-white px-6 py-3.5 text-xs dark:border-slate-800 dark:bg-slate-900">
<div className="flex min-w-0 items-center gap-2 truncate text-slate-500 dark:text-slate-400">
<span className="shrink-0">当前已选用:</span>
{appliedStructureId ? (
<span className="flex min-w-0 items-center gap-1 font-semibold text-[#CF1322] dark:text-[#9bc7ff]">
<Check className="h-3.5 w-3.5 shrink-0 text-[#F5222D] dark:text-[#4096ff]" />
<span className="flex min-w-0 items-center gap-1 font-semibold text-[#CF1322] dark:text-[#FA9196]">
<Check className="h-3.5 w-3.5 shrink-0 text-[#F5222D]" />
<span className="truncate">
{structures.find((item) => item.id === appliedStructureId)?.title ?? "当前结构"}
({reportStructureModeLabel(structures.find((item) => item.id === appliedStructureId)?.structureMode ?? "adaptive")})
@@ -546,7 +546,7 @@ export function WorkflowConfigDialog({
<Button
variant="ghost"
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
onClick={() => onOpenChange(false)}
>
完成
@@ -554,11 +554,11 @@ export function WorkflowConfigDialog({
</div>
</>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white md:flex-row dark:bg-[#0b1426]">
<aside className="report-structure-detail-scrollbar flex w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-neutral-100 bg-[#fafbfc] p-[22px] md:w-[320px] md:border-r md:border-b-0 lg:w-[340px] dark:border-[#263b5c] dark:bg-[#0b1426]">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white md:flex-row dark:bg-slate-900">
<aside className="report-structure-detail-scrollbar flex w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-neutral-100 bg-[#fafbfc] p-[22px] md:w-[320px] md:border-r md:border-b-0 lg:w-[340px] dark:border-slate-800 dark:bg-slate-950/40">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300">
<Info className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
@@ -570,9 +570,9 @@ export function WorkflowConfigDialog({
</p>
</section>
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300">
<Compass className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
@@ -584,9 +584,9 @@ export function WorkflowConfigDialog({
{detail.retrievalDirections.map((direction) => (
<div
key={direction}
className="flex items-center gap-1.5 rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
className="flex items-center gap-1.5 rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300"
>
<Tag className="h-3 w-3 shrink-0 text-rose-500 dark:text-[#69b1ff]" strokeWidth={2} aria-hidden />
<Tag className="h-3 w-3 shrink-0 text-rose-500" strokeWidth={2} aria-hidden />
<span className="leading-tight">{direction}</span>
</div>
))}
@@ -598,17 +598,17 @@ export function WorkflowConfigDialog({
)}
</section>
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300">
<Database className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
推荐检索来源
</span>
</div>
<span className="shrink-0 rounded-full border border-neutral-200/60 bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-500 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-400">
<span className="shrink-0 rounded-full border border-neutral-200/60 bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
{detail.retrievalSkills.length} 个渠道
</span>
</div>
@@ -617,7 +617,7 @@ export function WorkflowConfigDialog({
{detail.retrievalSkills.map((skill) => (
<span
key={skill}
className="rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1 text-xs leading-tight text-neutral-600 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
className="rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1 text-xs leading-tight text-neutral-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
>
{skill}
</span>
@@ -631,10 +631,10 @@ export function WorkflowConfigDialog({
</section>
</aside>
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-white dark:bg-[#0f172a]">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-neutral-100 bg-white px-6 py-3.5 dark:border-[#263b5c] dark:bg-[#0f172a]">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-white dark:bg-slate-900">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-neutral-100 bg-white px-6 py-3.5 dark:border-slate-800 dark:bg-slate-900">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300">
<FileText className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div>
<span className="truncate text-sm font-semibold text-neutral-900 dark:text-slate-100">
@@ -646,7 +646,7 @@ export function WorkflowConfigDialog({
<button
type="button"
onClick={() => void handleCopyOutline()}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 shadow-2xs transition-all hover:bg-neutral-50 hover:text-neutral-900 active:scale-95 dark:border-[#2a4164] dark:text-slate-300 dark:hover:border-[#2b8ffe]/70 dark:hover:bg-[#1c2d4a] dark:hover:text-slate-100"
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 shadow-2xs transition-all hover:bg-neutral-50 hover:text-neutral-900 active:scale-95 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
title="复制大纲文本"
>
{copiedOutlineId === detail.id ? (
@@ -661,15 +661,15 @@ export function WorkflowConfigDialog({
</>
)}
</button>
<span className="inline-flex items-center gap-1.5 rounded-lg border border-rose-200/80 bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 shadow-2xs dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600 dark:bg-[#69b1ff]" aria-hidden />
<span className="inline-flex items-center gap-1.5 rounded-lg border border-rose-200/80 bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 shadow-2xs dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600" aria-hidden />
{reportStructureModeLabel(detail.structureMode)}
</span>
</div>
</div>
<div className="report-structure-detail-scrollbar flex-1 space-y-6 overflow-y-auto bg-white p-6 md:p-8 dark:bg-[#0f172a]">
<div className="flex items-center justify-between gap-4 border-b border-neutral-100 pb-3 dark:border-[#263b5c]">
<div className="report-structure-detail-scrollbar flex-1 space-y-6 overflow-y-auto bg-white p-6 md:p-8 dark:bg-slate-900">
<div className="flex items-center justify-between gap-4 border-b border-neutral-100 pb-3 dark:border-slate-800">
<h3 className="min-w-0 truncate text-base font-bold tracking-tight text-neutral-900 dark:text-slate-100">
{detailOutlineTitle}
</h3>
@@ -784,10 +784,10 @@ function StructureCard({
}}
className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${
applied
? "border-[#F5222D] bg-gradient-to-b from-[#FEEDED]/90 via-[#FEFAFA] to-white shadow-sm ring-2 ring-[#F5222D]/20 dark:border-[#2b8ffe]/70 dark:from-[#0c3266]/70 dark:via-[#122347] dark:to-[#0e1c33] dark:ring-[#2b8ffe]/25"
? "border-[#F5222D] bg-gradient-to-b from-[#FEEDED]/90 via-[#FEFAFA] to-white shadow-sm ring-2 ring-[#F5222D]/20 dark:from-[#5C0011]/35 dark:via-slate-900 dark:to-slate-900"
: structure.enabled
? "border-slate-200/90 bg-white hover:border-[#FA9196] hover:bg-gradient-to-b hover:from-[#FEFAFA]/80 hover:to-white hover:shadow-md dark:border-[#263b5c]/90 dark:bg-[#111c31] dark:hover:border-[#2b8ffe]/70 dark:hover:bg-gradient-to-b dark:hover:from-[#152b4b] dark:hover:to-[#111c31]"
: "border-slate-200/60 bg-slate-50/80 opacity-75 hover:border-slate-300 hover:opacity-100 dark:border-[#263b5c]/50 dark:bg-[#10192b]/70 dark:hover:border-[#38527a]"
? "border-slate-200/90 bg-white hover:border-[#FA9196] hover:bg-gradient-to-b hover:from-[#FEFAFA]/80 hover:to-white hover:shadow-md dark:border-slate-800/90 dark:bg-slate-900 dark:hover:border-[#A8071A]/70"
: "border-slate-200/60 bg-slate-50/80 opacity-75 hover:border-slate-300 hover:opacity-100 dark:border-slate-800/50 dark:bg-slate-900/40"
}`}
>
<div>
@@ -799,7 +799,7 @@ function StructureCard({
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{applied && (
<span className="inline-flex items-center gap-1 rounded-full bg-[#F5222D] px-2.5 py-0.5 text-xs font-semibold text-white shadow-xs dark:bg-[#1677ff]">
<span className="inline-flex items-center gap-1 rounded-full bg-[#F5222D] px-2.5 py-0.5 text-xs font-semibold text-white shadow-xs">
<Check className="h-3 w-3 stroke-[3]" />
当前使用
</span>
@@ -807,18 +807,18 @@ function StructureCard({
<span
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${
structure.structureMode === "adaptive"
? "border border-[#FDDADC] bg-[#FEEDED] text-[#BC0D1E] dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]"
: "border border-rose-200/70 bg-rose-50/80 text-rose-700 dark:border-[#38527a] dark:bg-[#15243d] dark:text-[#b8d4ff]"
? "border border-[#FDDADC] bg-[#FEEDED] text-[#BC0D1E] dark:border-[#820014] dark:bg-[#5C0011]/40 dark:text-[#FA9196]"
: "border border-rose-200/70 bg-rose-50/80 text-rose-700 dark:border-rose-800/60 dark:bg-rose-950/40 dark:text-rose-300"
}`}
>
<Layers className="h-3 w-3 text-[#F74750] dark:text-[#69b1ff]" />
<Layers className="h-3 w-3 text-[#F74750]" />
{reportStructureModeLabel(structure.structureMode)}
</span>
<span
className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium ${
structure.enabled
? "border border-emerald-200/70 bg-emerald-50 text-emerald-700 dark:border-emerald-700/55 dark:bg-emerald-950/35 dark:text-emerald-300"
: "border border-slate-200 bg-slate-100 text-slate-500 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-400"
? "border border-emerald-200/70 bg-emerald-50 text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-300"
: "border border-slate-200 bg-slate-100 text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400"
}`}
>
{structure.enabled ? "已启用" : "已停用"}
@@ -841,7 +841,7 @@ function StructureCard({
type="button"
title="查看大纲结构"
aria-label={`查看 ${structure.title} 的大纲结构`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]"
onClick={onPreview}
>
<Eye className="h-4 w-4" />
@@ -850,7 +850,7 @@ function StructureCard({
type="button"
title="复制为新结构"
aria-label={`复制 ${structure.title}`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]"
onClick={onDuplicate}
disabled={toggling}
>
@@ -860,7 +860,7 @@ function StructureCard({
type="button"
title="编辑结构"
aria-label={`编辑 ${structure.title}`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]"
onClick={onEdit}
>
<Pencil className="h-4 w-4" />
@@ -884,7 +884,7 @@ function StructureCard({
{directions.length > 0 && (
<div className="mb-4">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-slate-500 dark:text-slate-400">
<Compass className="h-3.5 w-3.5 text-[#F74750] dark:text-[#69b1ff]" />
<Compass className="h-3.5 w-3.5 text-[#F74750]" />
<span>检索方向</span>
</div>
<div className="flex flex-wrap gap-1.5">
@@ -892,13 +892,13 @@ function StructureCard({
<span
key={direction}
title={direction}
className="inline-flex max-w-[200px] truncate rounded-md border border-[#FDDADC] bg-[#FEFAFA] px-2 py-0.5 text-xs text-slate-700 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
className="inline-flex max-w-[200px] truncate rounded-md border border-[#FDDADC] bg-[#FEFAFA] px-2 py-0.5 text-xs text-slate-700 dark:border-slate-700/60 dark:bg-slate-800/90 dark:text-slate-300"
>
{direction}
</span>
))}
{directions.length > 3 && (
<span className="inline-flex items-center rounded-md border border-[#FCB5B9] bg-[#FEEDED] px-1.5 py-0.5 text-[11px] font-medium text-[#F86C73] dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-[#9bc7ff]">
<span className="inline-flex items-center rounded-md border border-[#FCB5B9] bg-[#FEEDED] px-1.5 py-0.5 text-[11px] font-medium text-[#F86C73] dark:border-slate-700/40 dark:bg-slate-800/70">
+{directions.length - 3}
</span>
)}
@@ -908,7 +908,7 @@ function StructureCard({
</div>
<div
className="mt-auto flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-[#263b5c]/80"
className="mt-auto flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-slate-800/80"
onClick={(event) => event.stopPropagation()}
role="presentation"
>
@@ -934,8 +934,8 @@ function StructureCard({
disabled={toggling}
aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`}
onClick={() => onToggleEnabled(!structure.enabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 dark:focus-visible:ring-[#2b8ffe]/45 disabled:cursor-not-allowed disabled:opacity-60 ${
structure.enabled ? "bg-[#F5222D] dark:bg-[#1677ff]" : "bg-slate-300 dark:bg-[#2a4164]"
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60 ${
structure.enabled ? "bg-[#F5222D]" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
@@ -4,7 +4,7 @@ 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("hides the memory toggle while retaining the other iframe chat toolbar controls", () => {
test("includes the context compression toggle in the iframe chat toolbar", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
@@ -20,6 +20,7 @@ test("hides the memory toggle while retaining the other iframe chat toolbar cont
buttons.map((button) => button.key),
[
"writing",
"memory",
"references",
"contextCompression",
"pro",
@@ -28,22 +29,6 @@ test("hides the memory toggle while retaining the other iframe chat toolbar cont
);
});
test("can restore the iframe memory toggle when the feature is re-enabled", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
hideMemoryButton: false,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.some((button) => button.key === "memory"), true);
});
test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
@@ -15,8 +15,6 @@ type BuildIframeModeButtonsInput = {
agentSelected: boolean;
writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
hideMemoryButton?: boolean;
referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean;
ragModeLocked: boolean;
@@ -28,7 +26,6 @@ export function buildIframeModeButtons({
agentSelected,
writingModeEnabled,
memoryInjectionEnabled,
hideMemoryButton = true,
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
@@ -68,9 +65,7 @@ export function buildIframeModeButtons({
active: agentSelected,
},
];
return buttons.filter(
(button) =>
(referenceModeEntryEnabled || button.key !== "references") &&
(!hideMemoryButton || button.key !== "memory"),
);
return referenceModeEntryEnabled
? buttons
: buttons.filter((button) => button.key !== "references");
}
@@ -28,9 +28,8 @@ import type { JobStatus } from "@/roundtable-planning/lib/job-types";
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
/** 早期草稿可能缺这两个数组字段 —— hydrate 按空数组兜底。 */
interactiveMessages?: unknown[];
timelineEntries?: unknown[];
interactiveMessages: unknown[];
timelineEntries: unknown[];
}
export interface DraftStep2Snapshot {
@@ -4,7 +4,6 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning";
export type ThreadIdMap = Record<string, string>;
@@ -255,7 +254,8 @@ export interface StreamMultiAgentRequest {
*/
synthesisMode?: boolean;
/**
* 仅为兼容旧协议保留。总控 prompt 属于总控私有上下文,前端恒传 true,后端也会无条件隔离。
* 仅 leader 用。置 true 抑制「把总控本轮输入广播给各席位 thread」的预广播。DAG「每 stage
* 派活轮」置真——否则总控的派活 prompt 会进席位历史,弱模型据此模仿派活。后端缺省 false。
*/
suppressPreBroadcast?: boolean;
/**
@@ -291,14 +291,12 @@ export interface StreamMultiAgentRequest {
* show progressive status messages during the inevitable first-token wait.
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
* 帧中当前 tool call 的 args),供 UI 渲染路径 chip / 代码块等子节点。
* `toolCallId` 可将同名工具的连续调用与同一次调用的增量参数区分开。
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。
*/
onPhaseChange?: (
phase: StreamPhase,
name?: string,
args?: Record<string, unknown>,
toolCallId?: string,
) => void;
/**
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
@@ -470,9 +468,7 @@ function isFinalStatusFrame(value: unknown): value is {
* structured content blocks. Returns null when the frame is not an AI text
* chunk (e.g., tool calls, tool results, value snapshots).
*/
function extractAiTextDelta(
parsed: unknown,
): { content: string; reasoning: string; id: string } | null {
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
@@ -495,18 +491,9 @@ function extractAiTextDelta(
}
}
}
const additionalKwargs = obj.additional_kwargs;
const directReasoning = obj.reasoning_content ?? obj.reasoning;
const nestedReasoning =
additionalKwargs && typeof additionalKwargs === "object"
? (additionalKwargs as Record<string, unknown>).reasoning_content ??
(additionalKwargs as Record<string, unknown>).reasoning
: undefined;
const rawReasoning = nestedReasoning ?? directReasoning;
const reasoning = typeof rawReasoning === "string" ? rawReasoning : "";
if (!text && !reasoning) return null;
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { content: text, reasoning, id };
return { text, id };
}
/**
@@ -545,8 +532,8 @@ export async function streamMultiAgent(
skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false,
// 总控 prompt 永不进入席位 thread;后端同样强制执行,避免旧调用方破坏角色隔离。
suppress_pre_broadcast: req.suppressPreBroadcast ?? true,
// 仅 leader 用;DAG 派活轮抑制预广播,避免总控派活 prompt 污染席位 thread。省略 → false。
suppress_pre_broadcast: req.suppressPreBroadcast ?? false,
// 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。
parallel_no_file: req.parallelNoFile ?? false,
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
@@ -633,36 +620,27 @@ export async function streamMultiAgent(
let currentPhase: StreamPhase = "connecting";
let currentPhaseName = "";
let currentPhaseArgsSignature = "";
let currentPhaseToolCallId = "";
req.onPhaseChange?.("connecting");
// The normal chat renders provider reasoning from a distinct message field.
// Roundtable's bespoke SSE reader keeps that field inline so its existing
// `<think>` renderer remains the single display path for Steps 2 and 3.
const inlineAiDelta = createInlineReasoningDeltaFormatter();
const setPhase = (
next: StreamPhase,
name?: string,
args?: Record<string, unknown>,
toolCallId?: string,
) => {
const nextName = name ?? "";
const nextArgsSignature = phaseArgsSignature(args);
const nextToolCallId = toolCallId ?? "";
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
if (
next === currentPhase &&
nextName === currentPhaseName &&
nextArgsSignature === currentPhaseArgsSignature &&
nextToolCallId === currentPhaseToolCallId
nextArgsSignature === currentPhaseArgsSignature
) {
return;
}
currentPhase = next;
currentPhaseName = nextName;
currentPhaseArgsSignature = nextArgsSignature;
currentPhaseToolCallId = nextToolCallId;
req.onPhaseChange?.(next, name, args, toolCallId);
req.onPhaseChange?.(next, name, args);
};
/**
@@ -804,25 +782,12 @@ export async function streamMultiAgent(
const toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content;
const streamDelta = extractAiTextDelta(parsed);
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
ingestToolCallChunks(toolCallChunks);
// Some providers serialize `tool_calls` before their `args` have
// been decoded, leaving args as the raw JSON string. Treating that
// as a complete call suppresses the accumulated tool_call_chunks,
// so write_file reaches the sandbox with a path but no content.
const hasCompleteToolCalls =
Array.isArray(toolCalls) &&
toolCalls.length > 0 &&
toolCalls.every(
(toolCall) =>
!!toolCall &&
typeof toolCall === "object" &&
!!(toolCall as Record<string, unknown>).args &&
typeof (toolCall as Record<string, unknown>).args === "object",
);
Array.isArray(toolCalls) && toolCalls.length > 0;
let synthetic: SyntheticToolCall[] = [];
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
synthetic = buildSyntheticToolCalls();
@@ -847,34 +812,27 @@ export async function streamMultiAgent(
if (chunkType === "tool") {
setPhase("tool_result");
} else if (hasToolCall) {
// Pick the active (latest) call's name + args. A leader can dispatch
// several seats with the same `agent_orchestration` tool in one run;
// choosing the first accumulated call would leave the UI stuck on an
// earlier assignment until the final response arrives.
// Pick the most informative name + args we have so far.
// 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候,
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
const activeCall =
const firstCall =
effectiveToolCalls && effectiveToolCalls.length > 0
? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record<string, unknown>)
? (effectiveToolCalls[0] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[toolCallChunks.length - 1] as Record<string, unknown>)
? (toolCallChunks[0] as Record<string, unknown>)
: null;
const toolName =
activeCall && typeof activeCall.name === "string" ? activeCall.name : "";
firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
const args =
activeCall &&
typeof activeCall.args === "object" &&
activeCall.args !== null
? (activeCall.args as Record<string, unknown>)
firstCall &&
typeof firstCall.args === "object" &&
firstCall.args !== null
? (firstCall.args as Record<string, unknown>)
: undefined;
const toolCallId =
activeCall && typeof activeCall.id === "string" ? activeCall.id : undefined;
setPhase("tool_calling", toolName, args, toolCallId);
setPhase("tool_calling", toolName, args);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
(Boolean(streamDelta) ||
(typeof content === "string" && content.length > 0) ||
(Array.isArray(content) && content.length > 0))
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
) {
setPhase("streaming");
}
@@ -942,9 +900,8 @@ export async function streamMultiAgent(
const delta = extractAiTextDelta(parsed);
if (delta) {
const text = inlineAiDelta(delta);
if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`);
if (text) req.onTextDelta?.(text, delta.id);
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
req.onTextDelta?.(delta.text, delta.id);
}
req.onUpstreamFrame?.(parsed);
@@ -9,7 +9,7 @@
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowRightIcon,
ClockIcon,
@@ -593,9 +593,8 @@ function ChainSeatsFlow({
return (
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
{/* 同 DispatchChainModal:div.contents 代替 <Fragment>,规避 dev tagger 注入 data-lov-id 的警告。 */}
{stages.map((stageIds, stageIdx) => (
<div className="contents" key={stageIdx}>
<Fragment key={stageIdx}>
{stageIdx > 0 && (
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
@@ -610,7 +609,7 @@ function ChainSeatsFlow({
) : (
seatTag(stageIds[0])
)}
</div>
</Fragment>
))}
</div>
);
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff. Show more