sync update from LAN repo (source dhk-1 @ 3b1ec5b4)

This commit is contained in:
gozeros committed 2026-11-05 10:56:34 +08:00
1 parent da6521b6cb
commit 264e2200d6
995 files changed
+148105 -13399

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的空间前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_FRONTEND_URL= https://knowledgetoolweb.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
@@ -16,7 +16,7 @@
| 事实 | 位置 |
|------|------|
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
| Step2 编排循环(leader → special → leader …;到全局 `roundtable_room_limit.max_cycles` 配置轮次进入无工具的总控正文收口)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts`(recommend / chain / dag) |
| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) |
| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` |
| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` |
@@ -456,7 +456,7 @@ checkpoint,一路跑到 Step3 绘制完成。
只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。
3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。
4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。
5. **max_cycles / 预算**:后台与前端统一读取全局 `roundtable_room_limit.max_cycles`(默认 8,2..100),一次会商启动时锁定;到达配置轮次调用 `finalize_only=True`,关闭全部工具并要求总控输出最终总结正文。不能把到顶表现成“已停止”,也不能跳过总结直接进入 Step3。
6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker
需 sticky 或全靠 DB 态(推荐后者)。
7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。
@@ -311,13 +311,13 @@ chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = ch
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
- [x] 进入该 Tab 时 `listChains()` 拉本人链条渲染**卡片列表**(席位存活/名称用链条响应自带的读时计算富化字段 `seat.available`/`seat.live_name`,**不再实时拉 `listAgents()`**,见 §11):
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。存活判定改读 `seat.available`(undefined = 旧后端未标注 → 不误判删除)。
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
@@ -396,5 +396,42 @@ chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = ch
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
---
## 11. 席位富化:选链不再实时拉智能体列表(2026-10-23)
**背景**:选链全链路(弹窗打开 / 左栏点选 / 深链自动选链)此前每次都额外全量拉
`GET /api/agents`,只为两件事——席位**存活判断**(existingIds)与**最新改名**
(nameById)。慢网下这个大请求严重拖慢选链体验。
**方案**:席位展示所需信息(name 快照本就随链条落库、编辑器保存时刷新)之外,
「存活 + 最新名」由**后端在返回链条时读时计算**:
- `GET /api/roundtable-chains`(list/get/update 等)对每条链每个 seat 附带两个
**只读计算字段**(`ChainSeatResponseSchema`,不落库、create/update 请求 schema
不收,存量 seats JSON 零迁移):
- `available: bool` —— 席位智能体当前对请求用户可见(本人/内置/已发布、非私享、
非管理员隐藏,`agent_store.list_by_ids` 一次批量 SQL)且不是功能型内置
(`roundtable-intent/recommender/coordinator/report` + `roundtable-coordinator-*`
前缀,与前端 `filterRecommendCandidates` 排除集逐字对齐);
- `live_name: string | null` —— 智能体当前名称,查不到为 null。
- best-effort:agent store 不可用时跳过富化(字段缺省),前端按存活处理。
- 前端 `ChainSeat` 增加可选 `available` / `live_name`;`buildChainSeatSelection(chain,
candidates?)` 的 `candidates` 变为可选——不传时存活 = `seat.available !== false`
(undefined = 旧后端 → 视为存活,与旧版「拉取失败不判删」降级语义一致)、名称 =
`live_name ?? name ?? agent_id`。
- 去掉选链路径上的全部 `listAgents()`:`BusinessChainPicker`(打开只剩 1 个
`listChains` 请求)、`BusinessChainListDialog`(列表弹窗)、主页左栏点选
(**零网络请求**)、固定链(8BF 等)与深链自动选链。
- 「智能推荐」Tab 与链条编辑器的智能体池**保留** `listAgents()`——那两处真的需要
完整候选列表。
**兜底**:智能体在选链之后、进 Step 2 之前被删,`/api/multi-agent/init` 的后端
404 硬校验照旧兜底(现状即有,非本次引入)。
**测试**:后端 `tests/test_roundtable_chain_seat_enrichment.py`(纯函数 + schema
不落库 + store 往返);前端 `src/roundtable-planning/lib/business-chain.test.ts`
(`pnpm run test:business-chain`,esbuild 打包后 node:test)。
</content>
</invoke>
@@ -394,8 +394,8 @@ while !cancelled && cycles < MAX_CYCLES:
setConsensusPercentage(prev => min(95, prev + 8))
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
if cycles >= MAX_CYCLES:
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
if cycles == configuredMaxCycles:
leader(finalizeOnly: true, tools: [], prompt: "直接综合已有交付并输出最终正文")
clearAllStreamingSpeakers()
isOrchestrating = false
@@ -695,7 +695,7 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
### 12.3 调整 Step 2 编排上限
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
收口轮数来自全局设置 `roundtable_room_limit.max_cycles`(管理员设置弹窗「会商并发与收口」,默认 8、范围 2..100),旧服务端或读取失败时回退 `DEFAULT_MAX_CYCLES=8`。一次编排启动时锁定当时值;到达该轮不是停止,而是切换为 `finalizeOnly` 纯正文收口:总控禁用全部工具,综合已经取得的席位交付并输出最终方案;个别席位失败时如实标明缺口。前后台执行路径必须保持一致。
### 12.4 把 special 改回并行
@@ -1,5 +1,7 @@
# 技能知识归纳与“知识梳理”助手知识库
助手知识库的目录、正文、来源和任务记录读取已提交快照,不等待后台导入的全局写锁,也不重复统计整库内容。导入仍在提交前完成实体及 Wiki 对齐,旧数据在检索、导出时保留兼容对齐。页面和展示权限的读取支持取消及 30 秒超时,失败显示可重试错误,避免一直加载;大目录分页失败后停止自动续页并保留已加载正文。来源列表仅在打开「来源与同步」或「导入 / 导出记录」时请求。
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
+52 -2
View File
@@ -12,6 +12,40 @@
root.classList.add("dark");
} catch (e) {}
})();
// Chrome 102 has no AbortSignal.timeout (Chrome 103+). Install before
// any module so LangGraph SDK control requests can set a deadline.
(function () {
try {
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout === "function") {
return;
}
AbortSignal.timeout = function (milliseconds) {
var controller = new AbortController();
var timer = setTimeout(function () {
var reason;
try {
reason = new DOMException("signal timed out", "TimeoutError");
} catch (err) {
reason = new Error("signal timed out");
reason.name = "TimeoutError";
}
try {
controller.abort(reason);
} catch (err) {
controller.abort();
}
}, milliseconds);
controller.signal.addEventListener(
"abort",
function () {
clearTimeout(timer);
},
{ once: true },
);
return controller.signal;
};
} catch (err) {}
})();
</script>
<script>
// React 19's development profiler uses the User Timing Level 3
@@ -42,9 +76,25 @@
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
// Relative production assets need a directory URL. A mounted prefix such
// as /web must resolve against /web/. history.replaceState during parsing
// makes old Firefox abort the document (NS_BINDING_ABORTED) before the
// login bundle runs, so install a base URL instead of rewriting history.
(function () {
try {
var path = window.location.pathname;
if (path === "/" || /\/$/.test(path) || /\.[^/]+$/.test(path)) return;
if (document.querySelector("base")) return;
var base = document.createElement("base");
base.href = path + "/";
document.head.appendChild(base);
} catch (e) {}
})();
</script>
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
<title>CM 助手</title>
<script src="/runtime-config.js" data-runtime-config></script>
<script src="%BASE_URL%runtime-config.js" data-runtime-config></script>
</head>
<body>
<div id="root"></div>
+11 -2
View File
@@ -11,14 +11,23 @@
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test:thread-history": "node scripts/test-thread-history.mjs",
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
"test:answer-feedback": "node --experimental-strip-types --test src/components/workspace/agents/agent-feedback-visibility.test.ts src/components/workspace/messages/answer-feedback-eligibility.test.ts src/strategy-components/api/answer-feedback-batching.test.ts",
"test:retrieval-debug": "node --experimental-strip-types --test src/core/llmwiki/retrieval-debug.test.ts",
"test:abort-signal": "node --experimental-strip-types --test src/core/compat/abort-signal.test.ts",
"test:business-chain": "npx esbuild src/roundtable-planning/lib/business-chain.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/business-chain.test.mjs && node --test .runtime/unit-tests/business-chain.test.mjs",
"test:users-paged": "node scripts/test-users-paged.mjs",
"test:auth-entry": "node scripts/test-auth-entry.mjs",
"test:b5-1-theme": "node --experimental-strip-types --test src/core/auth/b5-1-theme.test.mjs",
"test:task-entry": "node --experimental-strip-types --test src/core/auth/task-entry.test.mjs",
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts src/components/workspace/visualizations/map-style.test.ts",
"test:composer-session": "node --experimental-strip-types --test src/core/settings/composer-scope.test.ts",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs",
"test:retry-kept": "npx esbuild src/core/threads/retry-kept-messages.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/retry-kept-messages.test.mjs && node --test .runtime/unit-tests/retry-kept-messages.test.mjs"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
+10 -1
View File
@@ -42,7 +42,7 @@ window.__CM_RUNTIME_CONFIG__ = {
// 地图瓦片地址模板:仅供普通问答消息列表中的交互地图使用。
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png",
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
@@ -112,6 +112,9 @@ window.__CM_RUNTIME_CONFIG__ = {
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
// B 系统(XWT检索)头像代理;按部署环境设置为浏览器可访问的地址。
VITE_AIPI_MINIO_PROXY_URL: "http://minio-proxy.b.sit.4.cn/facebook-proxy",
// B 系统 knowes 根地址。
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
@@ -128,6 +131,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_USE_REAL_DATA: "true",
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
// 为 true 时同时拦截 xdfx 的 task-action/actionDetail 与 mainnets/narratives-by-name。
VITE_MOCK_COP_TASK_DETAIL: "false",
// 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。
@@ -155,6 +159,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
// 对话历史页「收藏」右侧的「清空」按钮。
// "false" 隐藏;不填、删掉这一项,或填其它值,都显示。
// 确认后会删除当前账号的全部对话,以及该账号下所有智能体的聊天记录。
VITE_CHAT_HISTORY_CLEAR_VISIBLE: "true",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
@@ -78,6 +78,31 @@ const requiredLegacyFallbacks = [
pattern: /minHeight:[\s\S]*100vh[\s\S]*maxHeight:[\s\S]*100vh/,
description: "Skill file preview must size with vh, not dvh, for Chrome 102",
},
{
file: "src/core/compat/abort-signal.ts",
pattern: /export function installAbortSignalTimeout[\s\S]*AbortSignal/,
description: "LangGraph SDK timeoutMs needs AbortSignal.timeout for Chrome 102",
},
{
file: "src/core/api/api-client.ts",
pattern: /function installLegacyControlTimeout[\s\S]*AbortSignal\.timeout/,
description: "Chat send must skip AbortSignal.timeout when Chrome 102 lacks it",
},
{
file: "index.html",
pattern: /AbortSignal\.timeout = function/,
description: "Chrome 102 needs AbortSignal.timeout before any module loads",
},
{
file: "src/styles/globals.css",
pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/,
description: "Agent chat canvas needs an explicit token background for Chrome 102",
},
{
file: "src/styles/globals.css",
pattern: /@supports not \(translate: 0\)[\s\S]*\.dialog-content-center\s*\{\s*transform:\s*translate\(-50%,\s*-50%\);/,
description: "Dialog centering needs a transform fallback for Chrome 102",
},
];
for (const file of cssFiles) {
+40
View File
@@ -0,0 +1,40 @@
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("../", import.meta.url));
const require = createRequire(import.meta.url);
// Resolve Vite's installed dependency directly: pnpm does not expose a
// transitive esbuild binary in node_modules/.bin, and tests must run offline.
const viteRequire = createRequire(require.resolve("vite/package.json"));
const { build, stop } = viteRequire("esbuild");
const testEntries = {
"login-attempt": "src/core/auth/login-attempt.test.ts",
"login-entry": "src/core/auth/login-entry.test.ts",
"session-validation": "src/core/auth/session-validation.test.ts",
"auth-request-policy": "src/core/api/auth-request-policy.test.ts",
"login-transport": "src/core/auth/login-transport.test.ts",
};
const outputDirectory = path.join(root, ".runtime/unit-tests");
try {
await build({
absWorkingDir: root,
entryPoints: testEntries,
outdir: outputDirectory,
outExtension: { ".js": ".mjs" },
bundle: true,
platform: "node",
format: "esm",
});
} finally {
stop();
}
const result = spawnSync(process.execPath, ["--test", ...Object.keys(testEntries).map((name) => path.join(outputDirectory, `${name}.mjs`))], {
cwd: root,
stdio: "inherit",
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
@@ -0,0 +1,28 @@
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
// Use Vite's installed bundler, including pnpm layouts without a root esbuild bin.
const require = createRequire(import.meta.url);
const viteRequire = createRequire(require.resolve("vite/package.json"));
const { build } = viteRequire("esbuild");
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const suites = [
"src/core/threads/background-history.test.ts",
"src/core/api/stream-cursor-replay.test.ts",
"src/core/threads/stream-display.test.ts",
"src/core/threads/stream-recovery.test.ts",
"src/core/threads/stream-terminal-recovery.test.ts",
"src/core/threads/stream-reasoning-replay.test.ts",
"src/core/threads/project-metadata.test.ts",
];
const outdir = resolve(root, ".runtime/unit-tests/thread-history");
await build({
absWorkingDir: root, entryPoints: suites, outbase: "src/core", outdir,
bundle: true, platform: "node", format: "esm", define: { "import.meta.env": "{}" }, logLevel: "warning",
});
const files = suites.map((suite) => resolve(outdir, suite.replace("src/core/", "").replace(/\.ts$/, ".js")));
const result = spawnSync(process.execPath, ["--test", ...files], { cwd: root, stdio: "inherit" });
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = fileURLToPath(new URL("../", import.meta.url));
const require = createRequire(import.meta.url);
const viteRequire = createRequire(require.resolve("vite/package.json"));
const { build, stop } = viteRequire("esbuild");
const outfile = path.join(root, ".runtime/unit-tests/users-paged-api.mjs");
try {
await build({
absWorkingDir: root,
entryPoints: ["src/core/auth/api.ts"],
outfile,
bundle: true,
platform: "node",
format: "esm",
plugins: [{
name: "mock-user-api-dependencies",
setup(builder) {
builder.onResolve({ filter: /^@\/core\/(api\/fetch-client|config)$/ }, ({ path }) => ({ path, namespace: "mock" }));
builder.onLoad({ filter: /.*/, namespace: "mock" }, ({ path }) => ({
contents: path.endsWith("config")
? 'export const getBackendBaseURL = () => "https://example.test";'
: 'export const apiFetch = (...args) => globalThis.usersPagedFetch(...args);',
}));
},
}],
});
} finally {
stop();
}
const { listUsersPaged } = await import(pathToFileURL(outfile).href);
const fetchPage = (signal) => listUsersPaged({ q: "alice", limit: 20, offset: 40 }, signal);
test("whitelist requests pagination and search without statistics", async () => {
const users = { items: [{ id: "u1", email: "one@example.test", approved: false }], total: 41, approved_count: 40, limit: 20, offset: 40 };
globalThis.usersPagedFetch = async (url, init) => {
assert.equal(url, "https://example.test/api/v1/auth/users?limit=20&offset=40&include_counts=false&q=alice");
assert.ok(init.signal instanceof AbortSignal);
return Response.json(users);
};
assert.deepEqual(await fetchPage(), users);
});
test("server timeout detail reaches the retry UI", async () => {
globalThis.usersPagedFetch = async () => Response.json({ detail: "用户基础信息查询超时,请稍后重试" }, { status: 503 });
await assert.rejects(fetchPage(), /用户基础信息查询超时/);
});
test("client deadline aborts pending request and clears its timer", async (t) => {
let deadline;
const timer = {};
t.mock.method(globalThis, "setTimeout", (callback, delay) => {
assert.equal(delay, 20_000);
deadline = callback;
return timer;
});
const cleared = t.mock.method(globalThis, "clearTimeout", (value) => assert.equal(value, timer));
globalThis.usersPagedFetch = (_url, { signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
const pending = fetchPage();
deadline();
await assert.rejects(pending, /用户列表加载超时/);
assert.equal(cleared.mock.callCount(), 1);
});
test("query cancellation propagates without being reported as a timeout", async () => {
const query = new AbortController();
globalThis.usersPagedFetch = (_url, { signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
const pending = fetchPage(query.signal);
query.abort();
await assert.rejects(pending, { name: "AbortError" });
});
test("non-JSON errors preserve HTTP status", async () => {
globalThis.usersPagedFetch = async () => new Response("unavailable", { status: 502 });
await assert.rejects(fetchPage(), /HTTP 502/);
});
test("already cancelled requests remain cancellation errors", async () => {
const controller = new AbortController();
controller.abort();
globalThis.usersPagedFetch = async (_url, { signal }) => { signal.throwIfAborted(); };
await assert.rejects(fetchPage(controller.signal), { name: "AbortError" });
});
+40 -17
View File
@@ -6,11 +6,14 @@ import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { RouteLoadingFallback } from "@/components/route-loading";
import { Toaster } from "@/components/ui/sonner";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { RequireAccount } from "@/core/auth/require-account";
import { I18nProvider } from "@/core/i18n/context";
import { fetchYLogConfig } from "@/core/log/y-log-config";
import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { resolveB51ForcedTheme } from "@/core/auth/b5-1-theme";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import {
@@ -34,6 +37,7 @@ import {
const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
const MarkdownDocxEditorPage = lazy(() => import("./pages/MarkdownDocxEditorPage"));
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
const PublicScheduledTaskFullscreenPage = lazy(() =>
import("./pages/PublicScheduledTaskPage").then((module) => ({
@@ -79,6 +83,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
const location = useLocation();
const publicEmbedTheme = usePublicEmbedTheme();
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
const markdownToolMode = location.pathname.replace(/\/+$/, "") === "/tools/md-to-word";
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
useEffect(() => {
@@ -87,10 +92,14 @@ function AppProviders({ children }: { children: React.ReactNode }) {
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
useEffect(() => {
if (positionStandaloneMode) return;
if (positionStandaloneMode || markdownToolMode) return;
void fetchYLogConfig();
}, [positionStandaloneMode]);
}, [positionStandaloneMode, markdownToolMode]);
const forcedTheme = useMemo(() => {
// b5-1 任务分析深链最高优先:接入方传 ?theme= 用传入值,不传默认深蓝。
// 先于 embed 判定,避免 embed=1 缺省 light 盖掉本页的深蓝默认。
const b51Theme = resolveB51ForcedTheme(location.pathname, location.search);
if (b51Theme) return b51Theme;
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
const embedTheme = resolveForcedEmbedTheme(location.search);
@@ -129,15 +138,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
forcedTheme={forcedTheme}
>
<QueryClientProvider>
<AuthSyncWatcher disabled={positionStandaloneMode}>
<AuthSyncWatcher disabled={positionStandaloneMode || markdownToolMode}>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
{markdownToolMode ? children : (
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
)}
</I18nProvider>
</AuthSyncWatcher>
</QueryClientProvider>
@@ -164,11 +175,13 @@ export function App() {
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
);
const pageElement = positionStandaloneMode ? (
<ChatRuntime>
<PositionRoundtablePage compactChrome />
</ChatRuntime>
<RequireAccount allowLegacyAnonymous>
<ChatRuntime>
<PositionRoundtablePage compactChrome />
</ChatRuntime>
</RequireAccount>
) : (
<PageRoutes />
<RequireAccount><PageRoutes /></RequireAccount>
);
return (
@@ -190,6 +203,16 @@ export function App() {
}
/>
<Route path="/landing" element={<LandingPage />} />
{/* Anonymous standalone tool: no workspace navigation or login gate. */}
<Route
path="/tools/md-to-word"
element={
<div className="h-dvh overflow-hidden">
<MarkdownDocxEditorPage />
<Toaster />
</div>
}
/>
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
@@ -227,11 +250,11 @@ export function App() {
/>
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
element={<RequireAccount><DashboardStandalonePage /></RequireAccount>}
/>
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
<Route path="/official/*" element={<RequireAccount><OfficialDeerFlowEntry /></RequireAccount>} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
<Route path="/sites/:threadId/:siteName" element={<RequireAccount><SiteStudioPage /></RequireAccount>} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -998,11 +998,24 @@ export const PromptInputTextarea = ({
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
// stuck at its minimum height. A custom appearance height replaces that range.
let minBound = 64;
let maxBound = 192;
if (textarea.hasAttribute("data-composer-height")) {
const computed = window.getComputedStyle(textarea);
const minHeight = Number.parseFloat(computed.minHeight);
const maxHeight = Number.parseFloat(computed.maxHeight);
if (Number.isFinite(minHeight)) minBound = minHeight;
if (Number.isFinite(maxHeight)) maxBound = maxHeight;
}
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
const height = Math.max(
minBound,
Math.min(textarea.scrollHeight, maxBound),
);
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
textarea.style.overflowY =
textarea.scrollHeight > maxBound ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
@@ -0,0 +1,67 @@
/* DialogContent is rendered in a portal. Use the root theme selectors directly
* so deep-blue styles work regardless of Tailwind's variant compilation order. */
:root.dark .document-upload-dialog,
:root[theme-mode="dark"] .document-upload-dialog {
border-color: #2d4473 !important;
background-color: #0c1836 !important;
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__search,
:root[theme-mode="dark"] .document-upload-dialog__search {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .document-upload-dialog__search:hover,
:root[theme-mode="dark"] .document-upload-dialog__search:hover {
border-color: #4a6491 !important;
}
:root.dark .document-upload-dialog__search-input,
:root[theme-mode="dark"] .document-upload-dialog__search-input {
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__search-input::placeholder,
:root[theme-mode="dark"] .document-upload-dialog__search-input::placeholder,
:root.dark .document-upload-dialog__search-icon,
:root[theme-mode="dark"] .document-upload-dialog__search-icon {
color: #6e88b0 !important;
}
:root.dark .document-upload-dialog__search-button,
:root[theme-mode="dark"] .document-upload-dialog__search-button {
background-color: #1677ff !important;
}
:root.dark .document-upload-dialog__search-button:hover,
:root[theme-mode="dark"] .document-upload-dialog__search-button:hover {
background-color: #0958d9 !important;
}
:root.dark .document-upload-dialog__tabs,
:root[theme-mode="dark"] .document-upload-dialog__tabs {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .document-upload-dialog__tab,
:root[theme-mode="dark"] .document-upload-dialog__tab {
color: #6e88b0 !important;
}
:root.dark .document-upload-dialog__tab:hover,
:root[theme-mode="dark"] .document-upload-dialog__tab:hover {
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__tab--active,
:root[theme-mode="dark"] .document-upload-dialog__tab--active {
color: #91caff !important;
}
:root.dark .document-upload-dialog__tab-indicator,
:root[theme-mode="dark"] .document-upload-dialog__tab-indicator {
background-color: #4096ff !important;
}
@@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { readRuntimeEnv } from "@/env";
import "./document-upload-popup.css";
import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty";
@@ -599,11 +600,13 @@ const DocumentUploadPopupContent: FC<{
};
const yUserId = getYUserId();
const notebookToken = getNotebookToken();
const iframeTokenParam = notebookToken ? `&token=${encodeURIComponent(notebookToken)}` : "";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
className="document-upload-dialog w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground dark:border-[#2d4473] dark:bg-[#0c1836] dark:text-[#c5cfe0] [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 dark:[&>button]:text-[#c5cfe0]"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
@@ -613,7 +616,7 @@ const DocumentUploadPopupContent: FC<{
<div className="h-full overflow-y-auto">
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
{/* 搜索框 */}
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
<div className="document-upload-dialog__search relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300 dark:border-[#2d4473] dark:bg-[#122347] dark:hover:border-[#4a6491]">
<Textarea
placeholder="搜索资料..."
value={searchText}
@@ -624,24 +627,24 @@ const DocumentUploadPopupContent: FC<{
searchDataChange();
}
}}
className="max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
className="document-upload-dialog__search-input max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none dark:text-[#c5cfe0] dark:placeholder:text-[#6e88b0]"
rows={1}
/>
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
<div className="document-upload-dialog__search-icon absolute left-3 top-3 text-neutral-400 pointer-events-none dark:text-[#6e88b0]">
<Search className="h-4 w-4" strokeWidth={1.5} />
</div>
<Button
size="sm"
onClick={searchDataChange}
disabled={isSearching || !searchText.trim()}
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
className="document-upload-dialog__search-button absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40 dark:bg-[#1677ff] dark:hover:bg-[#0958d9]"
>
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
</Button>
</div>
{/* Tab 标签页 */}
<div className="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
<div className="document-upload-dialog__tabs border-b border-gray-200 rounded-t-xl px-4 bg-gray-50 dark:border-[#2d4473] dark:bg-[#122347]">
<div className="flex flex-nowrap overflow-x-auto">
{(
[
@@ -655,12 +658,12 @@ const DocumentUploadPopupContent: FC<{
<button
key={key}
onClick={() => void searchActiveChange(key)}
className={`relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900" : "text-gray-400 hover:text-gray-600"
className={`document-upload-dialog__tab ${activeTab === key ? "document-upload-dialog__tab--active" : ""} relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900 dark:text-[#91caff]" : "text-gray-400 hover:text-gray-600 dark:text-[#6e88b0] dark:hover:text-[#c5cfe0]"
}`}
>
{label}
{activeTab === key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
<span className="document-upload-dialog__tab-indicator absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full dark:bg-[#4096ff]" />
)}
</button>
))}
@@ -672,7 +675,7 @@ const DocumentUploadPopupContent: FC<{
{activeTab === "search" && !isSearching && (
<iframe
className="w-full h-[calc(100vh-200px)]"
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}${iframeTokenParam}`}
/>
)}
@@ -680,7 +683,7 @@ const DocumentUploadPopupContent: FC<{
{activeTab === "Y" && !isSearching && (
<iframe
className="w-full h-[calc(100vh-200px)]"
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}${iframeTokenParam}`}
/>
)}
@@ -0,0 +1,169 @@
/* 深蓝主题下,XWT 检索面板使用全局 deep-blue token,与弹窗外壳保持一致。 */
:root.dark .network-actor,
:root[theme-mode="dark"] .network-actor {
color: #c5cfe0;
}
:root.dark .network-actor__filter-surface,
:root[theme-mode="dark"] .network-actor__filter-surface {
background-color: #122347 !important;
}
:root.dark .network-actor__filter-panel,
:root[theme-mode="dark"] .network-actor__filter-panel {
border-color: #2d4473 !important;
background-color: #0f1d3e;
}
:root.dark .network-actor__filter-label,
:root.dark .network-actor__section-title,
:root.dark .network-actor__metric-label,
:root[theme-mode="dark"] .network-actor__filter-label,
:root[theme-mode="dark"] .network-actor__section-title,
:root[theme-mode="dark"] .network-actor__metric-label {
color: #c5cfe0 !important;
}
:root.dark .network-actor__filter-chip,
:root[theme-mode="dark"] .network-actor__filter-chip {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0 !important;
}
:root.dark .network-actor__filter-chip:hover,
:root[theme-mode="dark"] .network-actor__filter-chip:hover {
border-color: #4a6491 !important;
background-color: #243b68 !important;
}
:root.dark .network-actor__filter-chip[aria-pressed="true"],
:root[theme-mode="dark"] .network-actor__filter-chip[aria-pressed="true"] {
border-color: #1677ff !important;
background-color: #122347 !important;
color: #91caff !important;
}
:root.dark .network-actor__filter-chip:focus-visible,
:root[theme-mode="dark"] .network-actor__filter-chip:focus-visible,
:root.dark .network-actor__sort-button:focus-visible,
:root[theme-mode="dark"] .network-actor__sort-button:focus-visible {
outline: 2px solid #69b1ff;
outline-offset: 2px;
}
:root.dark .network-actor__platform-divider,
:root[theme-mode="dark"] .network-actor__platform-divider,
:root.dark .network-actor__metric-divider,
:root[theme-mode="dark"] .network-actor__metric-divider {
background-color: #4a6491 !important;
}
:root.dark .network-actor__results-surface,
:root[theme-mode="dark"] .network-actor__results-surface {
background-color: #122347 !important;
}
:root.dark .network-actor__results-header,
:root[theme-mode="dark"] .network-actor__results-header {
border-color: #2d4473 !important;
}
:root.dark .network-actor__title-accent,
:root[theme-mode="dark"] .network-actor__title-accent {
background-color: #1677ff !important;
}
:root.dark .network-actor__sort-button,
:root[theme-mode="dark"] .network-actor__sort-button {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0 !important;
}
:root.dark .network-actor__sort-button:hover,
:root[theme-mode="dark"] .network-actor__sort-button:hover {
border-color: #4a6491 !important;
background-color: #243b68 !important;
}
:root.dark .network-actor__sort-button--active,
:root[theme-mode="dark"] .network-actor__sort-button--active {
border-color: #1677ff !important;
background-color: #122347 !important;
color: #91caff !important;
}
:root.dark .network-actor__sort-button svg path,
:root[theme-mode="dark"] .network-actor__sort-button svg path {
fill: #4a6491;
}
:root.dark .network-actor__sort-button--active svg[data-direction="asc"] path:first-child,
:root[theme-mode="dark"] .network-actor__sort-button--active svg[data-direction="asc"] path:first-child,
:root.dark .network-actor__sort-button--active svg[data-direction="desc"] path:last-child,
:root[theme-mode="dark"] .network-actor__sort-button--active svg[data-direction="desc"] path:last-child {
fill: #4096ff;
}
:root.dark .network-actor__metric-value--selected,
:root[theme-mode="dark"] .network-actor__metric-value--selected {
color: #ffc53d !important;
}
:root.dark .network-actor__metric-value--total,
:root[theme-mode="dark"] .network-actor__metric-value--total,
:root.dark .network-actor__meta-icon,
:root[theme-mode="dark"] .network-actor__meta-icon {
color: #69b1ff !important;
}
:root.dark .network-actor__selection-bar,
:root[theme-mode="dark"] .network-actor__selection-bar {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0;
}
:root.dark .network-actor__card,
:root[theme-mode="dark"] .network-actor__card {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
box-shadow: 0 1px 4px rgba(6, 14, 36, 0.3);
color: #c5cfe0;
}
:root.dark .network-actor__card:hover,
:root[theme-mode="dark"] .network-actor__card:hover {
border-color: #4a6491 !important;
box-shadow: 0 4px 12px rgba(6, 14, 36, 0.45);
}
:root.dark .network-actor__card[data-selected="true"],
:root[theme-mode="dark"] .network-actor__card[data-selected="true"] {
border-color: #1677ff !important;
box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.24);
}
:root.dark .network-actor__avatar,
:root[theme-mode="dark"] .network-actor__avatar {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .network-actor__classification,
:root[theme-mode="dark"] .network-actor__classification {
background-color: rgba(245, 34, 45, 0.16) !important;
color: #ff7875 !important;
}
:root.dark .network-actor__tag,
:root[theme-mode="dark"] .network-actor__tag {
background-color: rgba(19, 194, 194, 0.14) !important;
color: #5cdbd3 !important;
}
:root.dark .network-actor input[type="checkbox"],
:root[theme-mode="dark"] .network-actor input[type="checkbox"] {
accent-color: #1677ff;
}
@@ -6,12 +6,33 @@ import {
useState,
} from "react";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
import {
CirclePlay,
FileText,
FolderTree,
Globe2,
Loader2,
Network,
Plus,
Tag,
Trash2,
UserRoundCheck,
UsersRound,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { authenticatedFetch } from "@/components/assistant-ui/document-upload-popup";
import { readRuntimeEnv } from "@/env";
import xLogo from "@/assets/platform/x.png";
import facebookLogo from "@/assets/platform/facebook.png";
import redditLogo from "@/assets/platform/reddit.png";
import pttLogo from "@/assets/platform/ptt.png";
import dcardLogo from "@/assets/platform/dcard.png";
import mobile01Logo from "@/assets/platform/mobile01.png";
import threadsLogo from "@/assets/platform/threads.png";
import instagramLogo from "@/assets/platform/ins.png";
import "./network-actor.css";
interface NetworkActorProps {
@@ -22,7 +43,6 @@ interface NetworkActorProps {
interface platformItem {
title: string;
icon: string;
checked: boolean;
}
@@ -31,6 +51,14 @@ interface languageItem {
checked: boolean;
}
interface ActorSearchFilters {
platforms: string[];
languages: string[];
direction: string;
sortBy: string;
sortDirection: "asc" | "desc";
}
interface PoliticsDirItem {
地区: string;
语言: string;
@@ -68,6 +96,86 @@ interface hx_info {
用户画像: 用户画像;
}
// 图片必须经 import 走 Vite 打包才能拼上 base(服务器带路径前缀时 public/ 绝对路径会 404)
const PLATFORM_LOGOS: Record<string, string> = {
x: xLogo,
facebook: facebookLogo,
reddit: redditLogo,
ptt: pttLogo,
dcard: dcardLogo,
mobile01: mobile01Logo,
threads: threadsLogo,
instagram: instagramLogo,
};
// 后端返回的平台名与筛选 chips 的大小写/缩写不完全一致(如 FB、INS、YT、TH),统一归一到上面的 key
const PLATFORM_KEY_RULES: Array<[RegExp, string]> = [
[/instagram|\bins\b|\big\b/, "instagram"],
[/facebook|\bfb\b/, "facebook"],
[/youtube|\byt\b|油管/, "youtube"],
[/threads|\bth\b/, "threads"],
[/mobile01|\bmobile\b/, "mobile01"],
[/dcard/, "dcard"],
[/ptt/, "ptt"],
[/reddit|\bre\b/, "reddit"],
[/twitter|推特/, "x"],
];
function platformLogoKey(platform: string): string {
const key = platform.trim().toLowerCase();
if (PLATFORM_LOGOS[key]) return key;
for (const [pattern, canonical] of PLATFORM_KEY_RULES) {
if (pattern.test(key)) return canonical;
}
return key;
}
function PlatformIcon({ platform, className = "" }: { platform: string; className?: string }) {
const key = platformLogoKey(platform);
const src = PLATFORM_LOGOS[key];
if (src) {
return <img src={src} className={`h-5 w-5 shrink-0 object-contain ${className}`} alt="" aria-hidden="true" />;
}
if (key === "youtube") {
return <CirclePlay className={`h-5 w-5 shrink-0 text-red-600 ${className}`} aria-hidden="true" />;
}
return <Globe2 className={`h-5 w-5 shrink-0 text-slate-500 ${className}`} aria-hidden="true" />;
}
function MetaIcon({
type,
className = "",
}: {
type: "followers" | "person" | "category" | "tags" | "summary" | "network";
className?: string;
}) {
const iconClassName = `network-actor__meta-icon h-4 w-4 shrink-0 ${className}`;
const icons = {
followers: <UsersRound className={iconClassName} aria-hidden="true" />,
person: <UserRoundCheck className={iconClassName} aria-hidden="true" />,
category: <FolderTree className={iconClassName} aria-hidden="true" />,
tags: <Tag className={iconClassName} aria-hidden="true" />,
summary: <FileText className={iconClassName} aria-hidden="true" />,
network: <Network className={iconClassName} aria-hidden="true" />,
};
return icons[type];
}
function SortDirectionIcon({ direction, active }: { direction: "asc" | "desc"; active: boolean }) {
const ascendingColor = active && direction === "asc" ? "#cf1322" : "#cbd5e1";
const descendingColor = active && direction === "desc" ? "#cf1322" : "#cbd5e1";
return (
<svg viewBox="0 0 12 18" data-direction={direction} className="h-5 w-3 shrink-0" aria-hidden="true">
<path d="M6 2 10 7H2L6 2Z" fill={ascendingColor} />
<path d="m6 16-4-5h8l-4 5Z" fill={descendingColor} />
</svg>
);
}
interface UserDataItem {
comment_count: number;
country: string | null;
@@ -114,9 +222,9 @@ interface UserDataItem {
birthday: string | null;
is_virtual: number;
isMedia: number;
hx_info: hx_info;
hx_info?: hx_info | null;
gender: string | null;
agentInfo: PoliticsDirItem;
agentInfo?: PoliticsDirItem | null;
profile_url: string | null;
tagId: string[];
user_name: string;
@@ -152,15 +260,15 @@ interface UserDataItem {
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
const [platforms, setPlatforms] = useState<platformItem[]>([
{ title: 'X', icon: 'x.png', checked: false },
{ title: 'Facebook', icon: 'facebook.png', checked: false },
{ title: 'Reddit', icon: 'reddit.png', checked: false },
{ title: 'PTT', icon: 'ptt.png', checked: false },
{ title: 'YouTube', icon: 'youtube.png', checked: false },
{ title: 'Dcard', icon: 'dcard.png', checked: false },
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
{ title: 'Threads', icon: 'threads.png', checked: false },
{ title: 'Instagram', icon: 'ins.png', checked: false },
{ title: 'X', checked: false },
{ title: 'Facebook', checked: false },
{ title: 'Reddit', checked: false },
{ title: 'PTT', checked: false },
{ title: 'YouTube', checked: false },
{ title: 'Dcard', checked: false },
{ title: 'Mobile01', checked: false },
{ title: 'Threads', checked: false },
{ title: 'Instagram', checked: false },
]);
const platformList = useRef<string[]>([]);
const [languages, setLanguages] = useState<languageItem[]>([
@@ -188,25 +296,37 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
const [directionType, setDirectionType] = useState<string>('TW');
const directionValue = useRef<string>('TW');
const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || 'https://ch1.b.uat.4.cn/consumer';
const backendUrl = typeof window !== 'undefined'
? (readRuntimeEnv("VITE_BACKEND_BASE_URL") || 'http://localhost:8001')
: 'http://localhost:8001';
const minioProxyUrl = readRuntimeEnv("VITE_AIPI_MINIO_PROXY_URL") || 'http://minio-proxy.b.sit.4.cn/facebook-proxy';
const actorBaseUrl = readRuntimeEnv("VITE_AIPI_B2ACTOR_URL") || 'http://ty.b.uat.4.cn';
const avatarUrl = (profileImage: string | null) => profileImage
? `${minioProxyUrl}?${new URLSearchParams({ url: profileImage, is_avatar: 'true' })}`
: `${actorBaseUrl}/manage/img/default.png`;
const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || 'http://localhost:8001';
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
const [isImporting, setIsImporting] = useState(false);
const searchChange = async () => {
const resolveSearchFilters = (overrides: Partial<ActorSearchFilters> = {}): ActorSearchFilters => ({
platforms: overrides.platforms ?? platformList.current,
languages: overrides.languages ?? languageList.current,
direction: overrides.direction ?? directionValue.current,
sortBy: overrides.sortBy ?? sortTypeValue.current,
sortDirection: overrides.sortDirection ?? sortValue.current as ActorSearchFilters["sortDirection"],
});
const searchChange = async (overrides: Partial<ActorSearchFilters> = {}) => {
setIsSearching(true);
const filters = resolveSearchFilters(overrides);
if (searchValue.current === 'network') {
await agentUserSearch();
await agentUserSearch(filters);
} else {
await agentGroupSearch();
await agentGroupSearch(filters);
}
};
const setDirectionChange = async (type: string) => {
setDirectionType(type);
directionValue.current = type;
await searchChange();
await searchChange({ direction: type });
};
const searchTypeChange = async (value: string) => {
@@ -216,135 +336,128 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
};
const allPlatformChange = async () => {
const nextPlatforms = platforms.map((item) => ({ ...item, checked: false }));
setAllPlatforms(true);
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
setPlatforms(nextPlatforms);
platformList.current = [];
await searchChange();
await searchChange({ platforms: [] });
};
const handleTogglePlatform = async (index: number) => {
setPlatforms((prev) => {
const newPlatforms = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
platformList.current = checkedTitles;
setAllPlatforms(newPlatforms.every((item) => !item.checked));
return newPlatforms;
});
await searchChange();
const nextPlatforms = platforms.map((item, itemIndex) => (
itemIndex === index ? { ...item, checked: !item.checked } : item
));
const checkedTitles = nextPlatforms.filter((item) => item.checked).map((item) => item.title);
setPlatforms(nextPlatforms);
platformList.current = checkedTitles;
setAllPlatforms(checkedTitles.length === 0);
await searchChange({ platforms: checkedTitles });
};
const allLanguageChange = async () => {
const nextLanguages = languages.map((item) => ({ ...item, checked: false }));
setAllLanguage(true);
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
setLanguages(nextLanguages);
languageList.current = [];
await searchChange();
await searchChange({ languages: [] });
};
const handleToggleLanguage = async (index: number) => {
setLanguages((prev) => {
const newItems = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
languageList.current = checkedTitles;
setAllLanguage(newItems.every((item) => !item.checked));
return newItems;
});
await searchChange();
const nextLanguages = languages.map((item, itemIndex) => (
itemIndex === index ? { ...item, checked: !item.checked } : item
));
const checkedTitles = nextLanguages.filter((item) => item.checked).map((item) => item.title);
setLanguages(nextLanguages);
languageList.current = checkedTitles;
setAllLanguage(checkedTitles.length === 0);
await searchChange({ languages: checkedTitles });
};
const sortTypeChange = async (type: string) => {
const nextSortDirection = (sortValue.current === 'desc' ? 'asc' : 'desc') as ActorSearchFilters["sortDirection"];
setSortType(type);
sortTypeValue.current = type;
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
if (type === 'fans_count') setFansSort(sortValue.current);
else if (type === '_score') setRelevanceSort(sortValue.current);
await searchChange();
sortValue.current = nextSortDirection;
if (type === 'fans_count') setFansSort(nextSortDirection);
else if (type === '_score') setRelevanceSort(nextSortDirection);
await searchChange({ sortBy: type, sortDirection: nextSortDirection });
};
const agentUserSearch = async () => {
const agentUserSearch = async (filters: ActorSearchFilters) => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyKey: ['党政要员'],
historyTitle: '',
keyword: searchText,
language: languageList.current,
language: filters.languages,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
platform: filters.platforms,
politicalStand: filters.direction,
sort: filters.sortBy,
asc: filters.sortDirection,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.list;
result.forEach((item) => {
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo['账号描述'];
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data.data.total);
setActorCount(data.data.zs_total);
}
if (!response.ok) return;
const data = await response.json();
const result: UserDataItem[] = Array.isArray(data?.data?.list) ? data.data.list : [];
result.forEach((item) => {
item.profile_image = avatarUrl(item.profile_image);
item.classification = `${item.political_stand ?? ''}-${item.history_key ?? ''}-${item.history_title ?? ''}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo?.['账号描述'] || item.hx_info?.['用户画像']?.['账号描述'] || '';
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data?.data?.total ?? 0);
setActorCount(data?.data?.zs_total ?? 0);
} catch (error) {
console.error('Search error:', error);
} finally {
setIsSearching(false);
}
};
const agentGroupSearch = async () => {
const agentGroupSearch = async (filters: ActorSearchFilters) => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyKey: ['党政要员'],
historyTitle: '',
keyword: searchText,
language: languageList.current,
language: filters.languages,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
platform: filters.platforms,
politicalStand: filters.direction,
sort: filters.sortBy,
asc: filters.sortDirection,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.data;
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}`;
item.title = item.group_name || item.groupName;
if (item.hx_info?.['用户画像']) {
item.content = item.hx_info['用户画像']['账号描述'];
}
item.url = '';
});
setUserList(result);
setActorCount(data.data.total);
}
if (!response.ok) return;
const data = await response.json();
const result: UserDataItem[] = Array.isArray(data?.data?.data) ? data.data.data : [];
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = avatarUrl(item.profile_image);
item.classification = `${item.political_stand ?? ''}`;
item.title = item.group_name || item.groupName;
item.content = item.hx_info?.['用户画像']?.['账号描述'] || item.agentInfo?.['账号描述'] || '';
item.url = '';
});
setUserList(result);
setActorCount(data?.data?.total ?? 0);
} catch (error) {
console.error('Search error:', error);
} finally {
setIsSearching(false);
}
};
@@ -411,122 +524,128 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
}, []);
return (
<div>
<div className="bg-white rounded-[5px] p-[10px]">
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="network-actor">
<div className="network-actor__filter-surface bg-white rounded-[5px] p-[10px]">
<div className="network-actor__filter-panel border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">所属平台:</span>
<span className="network-actor__filter-label text-[#3D3D3D]">所属平台:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
<button
type="button"
aria-pressed={allPlatforms}
className={`network-actor__filter-chip flex cursor-pointer items-center rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${allPlatforms ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
onClick={() => void allPlatformChange()}
>全部</span>
>全部</button>
{platforms.map((item, index) => (
<span
<button
type="button"
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
aria-pressed={item.checked}
className={`network-actor__filter-chip flex cursor-pointer items-center rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${item.checked ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
onClick={() => void handleTogglePlatform(index)}
>
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
<span className="ml-[5px]">{item.title}</span>
</span>
<PlatformIcon platform={item.title} className="h-5 w-5" />
<span className="network-actor__platform-divider mx-2 h-3 w-px bg-[#dbdbdb]" aria-hidden="true"></span>
<span>{item.title}</span>
</button>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">常用语言:</span>
<span className="network-actor__filter-label text-[#3D3D3D]">常用语言:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
<button
type="button"
aria-pressed={allLanguage}
className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${allLanguage ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
onClick={() => void allLanguageChange()}
>全部</span>
>全部</button>
{languages.map((item, index) => (
<span
<button
type="button"
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
aria-pressed={item.checked}
className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${item.checked ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
onClick={() => void handleToggleLanguage(index)}
>
<span>{item.title}</span>
</span>
</button>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">方向:</span>
<span className="network-actor__filter-label text-[#3D3D3D]">方向:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
<button type="button" aria-pressed={directionType === 'TW'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === 'TW' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('TW')}>W方向</button>
<button type="button" aria-pressed={directionType === 'US'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === 'US' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('US')}>U方向</button>
<button type="button" aria-pressed={directionType === '其他'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === '其他' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('其他')}>其他</button>
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">类型:</span>
<span className="network-actor__filter-label text-[#3D3D3D]">类型:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
<button type="button" aria-pressed={searchType === 'network'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${searchType === 'network' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void searchTypeChange('network')}>网络XWT</button>
<button type="button" aria-pressed={searchType === 'group'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${searchType === 'group' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void searchTypeChange('group')}>群组XWT</button>
</span>
</span>
</div>
</div>
</div>
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="network-actor__results-surface bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="network-actor__results-header flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="flex items-center ml-[10px]">
<div className="title-bar mr-[8px]"></div>
<div className="text-[#454859]">用户表格管理</div>
<div className="network-actor__title-accent mr-2 h-5 w-1 rounded-full bg-[#cf1322]"></div>
<div className="network-actor__section-title text-[#454859]">用户表格管理</div>
</div>
<div className="flex items-center">
<div
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
<button
type="button"
aria-label={`按粉丝数排序,当前${fansSort === 'asc' ? '升序' : '降序'}`}
className={`network-actor__sort-button ${sortType === 'fans_count' ? 'network-actor__sort-button--active' : ''} relative flex cursor-pointer items-center rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${sortType === 'fans_count' ? 'border-[#cf1322] bg-[#fffafa] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:border-slate-300 hover:bg-slate-50'}`}
onClick={() => void sortTypeChange('fans_count')}
>
<span className="leading-[16px]">粉丝数排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<div
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
<span className="ml-2"><SortDirectionIcon direction={fansSort as "asc" | "desc"} active={sortType === 'fans_count'} /></span>
</button>
<button
type="button"
aria-label={`按相关度排序,当前${relevanceSort === 'asc' ? '升序' : '降序'}`}
className={`network-actor__sort-button ${sortType === '_score' ? 'network-actor__sort-button--active' : ''} relative ml-2 flex cursor-pointer items-center rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${sortType === '_score' ? 'border-[#cf1322] bg-[#fffafa] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:border-slate-300 hover:bg-slate-50'}`}
onClick={() => void sortTypeChange('_score')}
>
<span className="leading-[16px]">相关度排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<span className="ml-2"><SortDirectionIcon direction={relevanceSort as "asc" | "desc"} active={sortType === '_score'} /></span>
</button>
{searchType === 'network' && (
<>
<div className="flex items-center ml-[35px]">
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
<div className="network-actor__metric flex items-center ml-[35px]">
<MetaIcon type="network" className="h-6 w-6 text-[#d97b00]" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
<div className="network-actor__metric-label text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="network-actor__metric-value network-actor__metric-value--selected text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
</div>
</div>
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
<div className="network-actor__metric-divider ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
</>
)}
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
<div className={`network-actor__metric flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<MetaIcon type="network" className="h-6 w-6 text-[#cf1322]" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
<div className="network-actor__metric-label text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="network-actor__metric-value network-actor__metric-value--total text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
</div>
</div>
</div>
</div>
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<div className="network-actor__selection-bar flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
@@ -551,9 +670,13 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : null}
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
<div className={`grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4${isSearching ? ' hidden' : ''}`}>
{userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative">
<div
key={index}
data-selected={item.checked ?? false}
className={`network-actor__card relative rounded-lg border bg-white p-4 shadow-sm transition-[border-color,box-shadow] duration-200 motion-reduce:transition-none hover:border-[#f1b5ba] hover:shadow-md ${item.checked ? 'border-[#cf1322] ring-1 ring-[#cf1322]/15' : 'border-[#e5e7eb]'}`}
>
<div className="absolute top-[5px] right-[10px]">
<input
type="checkbox"
@@ -563,21 +686,21 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
/>
</div>
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
<img src={item.profile_image ?? ''} className="network-actor__avatar h-[97px] w-[97px] rounded-md border border-[#e5e7eb] bg-slate-50 object-cover" alt="" />
<div className="flex-1 min-w-0 pl-[10px]">
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
<div className="flex justify-between mb-[10px]">
<div className="flex items-center">
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
<MetaIcon type="followers" className="mr-[5px] text-[#d97b00]" />
<span>粉丝数:{item.fans_count || '-'}</span>
</div>
<div className="flex items-center">
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
<PlatformIcon platform={item.platform} className="mr-[5px]" />
<span>{item.platform}</span>
</div>
</div>
<div className="flex items-center">
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
<MetaIcon type="person" className="mr-[5px] text-[#cf1322]" />
<span>实体人:{item.name}</span>
</div>
</div>
@@ -585,29 +708,29 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
<MetaIcon type="category" className="mr-[5px] text-[#cf1322]" />
<span>分类:</span>
</div>
<div className="flex-1 min-w-0">
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
<div className="network-actor__classification inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
</div>
</div>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<MetaIcon type="tags" className="mr-[5px] text-[#1eb07f]" />
<span>标签:</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-[5px]">
{(item.hobbies ?? []).map((hobbie) => (
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
<div key={hobbie} className="network-actor__tag h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
))}
</div>
</div>
</div>
<div className="flex items-baseline mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<MetaIcon type="summary" className="mr-[5px] text-slate-500" />
<span>简介:</span>
</div>
<div className="flex-1 min-w-0">
+34 -18
View File
@@ -1,14 +1,19 @@
"use client";
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { useLocalSettings } from "@/core/settings";
import {
applyFontScaleToDocument,
normalizeFontScale,
normalizeFontScaleMode,
} from "@/core/ui/page-zoom";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
@@ -21,25 +26,36 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
{ value: 1.25, label: "特大", px: 20 },
];
export const FONT_SCALE_MODE_OPTIONS: {
value: "text" | "page";
label: string;
description: string;
}[] = [
{
value: "text",
label: "仅文字",
description: "只放大文字,间距和布局保持原样。",
},
{
value: "page",
label: "整页缩放",
description: "文字、间距、图标和写死的像素一起放大。",
},
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = settings.display;
const { fontScale, fontScaleMode } = settings.display;
useEffect(() => {
const scale =
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
? fontScale
: 1;
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
useLayoutEffect(() => {
applyFontScaleToDocument(
normalizeFontScaleMode(fontScaleMode),
normalizeFontScale(fontScale),
);
}, [fontScale, fontScaleMode]);
return null;
}
@@ -38,6 +38,7 @@ export function HtmlPreviewFrame({
return (
<iframe
key={url ?? "html-preview"}
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
@@ -1,3 +1,5 @@
import { toCssPx } from "@/core/ui/page-zoom";
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
@@ -9,8 +11,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
+8 -8
View File
@@ -4,19 +4,19 @@
top: 4rem;
z-index: 40;
width: 98px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
flex-shrink: 0;
}
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
.sidebar-contnet--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
.sidebar-contnet--header-hidden .item-nav-content {
height: calc(100vh - 20px);
min-height: calc(100vh - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
}
.sidebar-contents {
@@ -160,8 +160,8 @@
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
min-height: calc(100vh - 4rem - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
.light-sidebar--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
@@ -194,7 +194,7 @@ export function PageSidebar({
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
+20 -1
View File
@@ -16,6 +16,7 @@ import {
Settings,
ShieldAlert,
ShieldCheck,
ScrollText,
Timer,
User,
Wrench,
@@ -277,6 +278,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
@@ -491,7 +500,9 @@ function LegacyAccordionSidebar({
/>
<aside
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
headerHidden
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
} ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
@@ -604,6 +615,14 @@ function LegacyAccordionSidebar({
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
+2 -2
View File
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
data-slot="dialog-overlay"
className={cn(
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 bg-black/50",
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[200] bg-black/50",
className,
)}
{...props}
@@ -90,7 +90,7 @@ const DialogContent = React.forwardRef<
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-[200] grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
@@ -46,7 +46,7 @@ const DropdownMenuContent = React.forwardRef<
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
@@ -240,7 +240,7 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
+1 -1
View File
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
ref={ref}
data-slot="select-content"
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -4,7 +4,7 @@ import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useMemo } from "react";
@@ -29,13 +29,13 @@ export function MarkdownCodeEditor({
}) {
const { resolvedTheme } = useTheme();
const extensions = useMemo(
() => [markdown({ base: markdownLanguage, codeLanguages: languages })],
() => [markdown({ base: markdownLanguage, codeLanguages: languages }), EditorView.lineWrapping],
[],
);
return (
<div className={cn("grid min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
<section className="flex min-h-0 flex-col border-b lg:border-r lg:border-b-0">
<div className={cn("grid min-w-0 min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
<section className="flex min-w-0 min-h-0 flex-col border-b lg:border-r lg:border-b-0">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
Markdown 编辑
</div>
@@ -50,14 +50,14 @@ export function MarkdownCodeEditor({
highlightActiveLineGutter: false,
lineNumbers: true,
}}
className="min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
className="min-w-0 min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
/>
</section>
<section className="flex min-h-0 flex-col bg-muted/10">
<section className="flex min-w-0 min-h-0 flex-col bg-muted/10">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
实时渲染
</div>
<div className="min-h-[42vh] flex-1 overflow-auto p-4">
<div className="min-w-0 min-h-[42vh] flex-1 overflow-auto p-4">
<MarkdownContent
content={value || "*暂无正文*"}
isLoading={false}
@@ -83,7 +83,7 @@ function UserInfoPanel({ user }: { user: UserListItem }) {
<Badge variant="secondary">普通用户</Badge>
)}
</dd>
<dt className="text-muted-foreground font-medium">问答次数</dt>
<dt className="text-muted-foreground font-medium">近30天问答次数</dt>
<dd className="tabular-nums">{user.qa_count}</dd>
<dt className="text-muted-foreground font-medium">Skills</dt>
<dd className="tabular-nums">{user.skill_count}</dd>
@@ -304,7 +304,7 @@ export function AdminUserHub() {
const [userQuery, setUserQuery] = useState("");
// 活动审计 放到前面 → 默认进入审计标签页。
const [activeTab, setActiveTab] = useState<"audit" | "info" | "memory">("audit");
// 是否按问答次数(降序)排序用户列表。
// 是否按近 30 天问答次数(降序)排序用户列表。
const [sortByQa, setSortByQa] = useState(false);
const filteredUsers = useMemo(() => {
@@ -332,7 +332,7 @@ export function AdminUserHub() {
<div className="border-b px-6 py-4">
<h1 className="text-xl font-semibold">Admin 用户中心</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
管理用户信息、审计历史问答、编辑用户记忆。
管理用户信息、审计近 30 天问答、编辑用户记忆。
</p>
</div>
@@ -357,10 +357,10 @@ export function AdminUserHub() {
? "border-primary/40 bg-primary/5 text-primary"
: "text-muted-foreground hover:bg-muted",
)}
title="按问答次数从高到低排序"
title="按近 30 天问答次数从高到低排序"
>
<ArrowDownWideNarrow className="size-3.5" />
{sortByQa ? "按问答次数排序中" : "按问答次数排序"}
{sortByQa ? "按近30天问答排序中" : "按近30天问答排序"}
</button>
<ScrollArea className="min-h-0 flex-1">
{usersLoading && (
@@ -395,7 +395,7 @@ export function AdminUserHub() {
)}
<span
className="text-muted-foreground shrink-0 tabular-nums text-xs"
title="问答次数"
title="近 30 天问答次数"
>
{u.qa_count ?? 0}
</span>
@@ -27,6 +27,7 @@ import {
type ToolMetric,
type ToolMetricsFilters,
} from "@/core/tool-metrics/api";
import { getAdminQueryDateTimeBounds } from "@/core/admin/query-window";
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
@@ -68,6 +69,7 @@ function categoryLabel(category?: string | null): string {
export function ToolMetricsPanel() {
const { theme } = useTheme();
const queryDateTimeBounds = getAdminQueryDateTimeBounds();
const [page, setPage] = useState(0);
const [tool, setTool] = useState("");
const [user, setUser] = useState("");
@@ -77,8 +79,8 @@ export function ToolMetricsPanel() {
const [kind, setKind] = useState<KindFilter>("skill");
const [status, setStatus] = useState<StatusFilter>("all");
const [category, setCategory] = useState<string>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [since, setSince] = useState(() => getAdminQueryDateTimeBounds().min);
const [until, setUntil] = useState(() => getAdminQueryDateTimeBounds().max);
const [exporting, setExporting] = useState(false);
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
@@ -153,14 +155,15 @@ const errorToastDarkOptions = {
};
const resetFilters = () => {
const nextBounds = getAdminQueryDateTimeBounds();
setTool("");
setUser("");
setSkill("");
setKind("skill");
setStatus("all");
setCategory("all");
setSince("");
setUntil("");
setSince(nextBounds.min);
setUntil(nextBounds.max);
setPage(0);
};
@@ -180,7 +183,7 @@ const errorToastDarkOptions = {
<h2 className="text-lg font-semibold">工具调用统计</h2>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次工具 / 技能 / MCP 调用,并对失败进行归类:检索为空、IP
被封、限流、超时、鉴权失败等。后台无条件记录。
被封、限流、超时、鉴权失败等。后台无条件记录,默认仅查询最近 30 天。
</p>
</div>
<Button onClick={handleExport} disabled={exporting}>
@@ -391,8 +394,10 @@ const errorToastDarkOptions = {
<Input
type="datetime-local"
value={since}
min={queryDateTimeBounds.min}
max={until || queryDateTimeBounds.max}
onChange={(e) => {
setSince(e.target.value);
setSince(e.target.value || queryDateTimeBounds.min);
setPage(0);
}}
/>
@@ -402,8 +407,10 @@ const errorToastDarkOptions = {
<Input
type="datetime-local"
value={until}
min={since || queryDateTimeBounds.min}
max={queryDateTimeBounds.max}
onChange={(e) => {
setUntil(e.target.value);
setUntil(e.target.value || queryDateTimeBounds.max);
setPage(0);
}}
/>
@@ -1,8 +1,10 @@
"use client";
import { Loader2 } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
@@ -17,6 +19,11 @@ export function UserManagementPage() {
title={t.adminUsers.title}
description={t.adminUsers.description}
>
<div className="mb-4 flex justify-end">
<Button variant="outline" asChild>
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
</Button>
</div>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -4,6 +4,7 @@ import {
BotIcon,
ClockIcon,
CopyIcon,
EyeIcon,
EyeOffIcon,
GlobeIcon,
LockIcon,
@@ -59,6 +60,7 @@ 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 { AgentChatLinkFields } from "@/components/workspace/agents/agent-chat-link-fields";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
@@ -75,7 +77,15 @@ import {
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import type { Agent, AgentChatLink, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import {
agentChatLinksEqual,
canonicalAgentChatLink,
isAllowedAgentChatUrl,
openResolvedAgentChatUrl,
} from "@/core/agents/chat-link";
import { emptyAgentChatLink } from "@/core/agents/types";
import { appendResolvedRouteParams } from "@/strategy-components/lib/light-app";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
@@ -92,6 +102,11 @@ interface AgentCardProps {
/** Click the 发布者 line to filter the gallery to that publisher. Receives the
* owner user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
/** Admin console (智能体管理) only: renders the hide/restore eye button.
* Undefined everywhere else, so the gallery never shows it. */
onToggleHidden?: (next: boolean) => void | Promise<void>;
/** Disable the eye button while the admin toggle request is in flight. */
toggleHiddenPending?: boolean;
}
interface BadgeItem {
@@ -203,7 +218,13 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
);
}
export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) {
export function AgentCard({
agent,
layout = "card",
onPublisherClick,
onToggleHidden,
toggleHiddenPending = false,
}: AgentCardProps) {
const isList = layout === "list";
const { t } = useI18n();
const router = useRouter();
@@ -214,9 +235,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
// The full skill catalog is only needed by the edit dialog. Cards render
// Chinese names from `agent.skill_labels` on the list response.
const { skills, isLoading: isSkillsLoading } = useSkills({ enabled: editOpen });
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
@@ -234,6 +257,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const [editSeatSkillDirective, setEditSeatSkillDirective] = useState(
agent.seat_skill_directive ?? false,
);
const [editModelPromptSuffixEnabled, setEditModelPromptSuffixEnabled] = useState(
agent.model_prompt_suffix_enabled ?? true,
);
const [editChatLink, setEditChatLink] = useState<AgentChatLink>(
() => agent.chat_link ?? emptyAgentChatLink(),
);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
@@ -282,6 +311,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
setSelectedSkills(agent.skills ?? []);
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
setEditSeatSkillDirective(agent.seat_skill_directive ?? false);
setEditModelPromptSuffixEnabled(agent.model_prompt_suffix_enabled ?? true);
setEditChatLink(agent.chat_link ?? emptyAgentChatLink());
}, [
agent.common_questions,
agent.description,
@@ -291,6 +322,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
agent.square_id,
agent.skills,
agent.seat_skill_directive,
agent.model_prompt_suffix_enabled,
agent.chat_link,
editOpen,
]);
@@ -306,12 +339,28 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
// 列表响应不带 config 额外字段(seat_skill_directive 为 null),详情才有真值。
setEditSeatSkillDirective(agentDetail.seat_skill_directive ?? false);
setEditModelPromptSuffixEnabled(agentDetail.model_prompt_suffix_enabled ?? true);
setEditChatLink(agentDetail.chat_link ?? emptyAgentChatLink());
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
function handleChat() {
async function handleChat() {
let link = agent.chat_link;
if (link === undefined) {
try {
link = (await getAgent(agent.id)).chat_link ?? null;
} catch {
link = null;
}
}
const target = canonicalAgentChatLink(link);
if (target && isAllowedAgentChatUrl(target.url)) {
const href = appendResolvedRouteParams(target.url, target.route_params);
openResolvedAgentChatUrl(href, target.open_mode, (path) => router.push(path));
return;
}
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
@@ -334,9 +383,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
// row to carry the source agent's personality into the copy. Fall back
// to whatever the list happened to include if the detail fetch fails.
let soul = agent.soul ?? "";
let chatLink = canonicalAgentChatLink(agent.chat_link);
try {
const detail = await getAgent(agent.id);
soul = detail.soul ?? soul;
chatLink = canonicalAgentChatLink(detail.chat_link);
} catch {
// keep the list fallback
}
@@ -347,6 +398,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
skills: agent.skills ?? [],
model: agent.model ?? null,
tool_groups: agent.tool_groups ?? null,
chat_link: chatLink,
// A copy starts as a private, unpublished draft the owner can refine
// before sharing — never inherit the source's published/private state.
published: false,
@@ -399,6 +451,24 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
request.common_questions = nextQuestions;
if (editSeatSkillDirective !== ((agentDetail?.seat_skill_directive ?? agent.seat_skill_directive) ?? false))
request.seat_skill_directive = editSeatSkillDirective;
if (editModelPromptSuffixEnabled !== ((agentDetail?.model_prompt_suffix_enabled ?? agent.model_prompt_suffix_enabled) ?? true))
request.model_prompt_suffix_enabled = editModelPromptSuffixEnabled;
const nextChatLink = canonicalAgentChatLink(editChatLink);
if (nextChatLink && !isAllowedAgentChatUrl(nextChatLink.url)) {
toast.error("对话链接只支持 http(s) 地址,或以 /、#/ 开头的站内路径");
return;
}
if (
editChatLink.route_params.some((param) => !param.key.trim()) &&
nextChatLink
) {
toast.error("路由参数键名不可为空");
return;
}
const baselineChatLink = agentDetail?.chat_link ?? agent.chat_link;
if (!agentChatLinksEqual(nextChatLink, baselineChatLink)) {
request.chat_link = nextChatLink;
}
if (Object.keys(request).length === 0) {
setEditOpen(false);
@@ -590,6 +660,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
私密
</Badge>
)}
{agent.hidden && (
<Badge
variant="outline"
className="border-zinc-500/40 bg-zinc-500/10 text-zinc-600 dark:text-zinc-300 text-xs"
title="已被管理员隐藏:除本管理页外不再展示,但仍可正常使用"
>
<EyeOffIcon className="size-3" />
已隐藏
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
@@ -646,15 +726,6 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</div>
) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
const skillNameZhMap = useMemo(() => {
const map = new Map<string, string>();
for (const s of skills) {
if (s.name_zh?.trim()) map.set(s.name, s.name_zh.trim());
}
return map;
}, [skills]);
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
@@ -677,7 +748,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
setDetailSkillName(skill);
}}
>
{skillNameZhMap.get(skill) ?? desensitize(skill)}
{agent.skill_labels?.[skill]?.trim() || desensitize(skill)}
</Badge>
),
})),
@@ -783,6 +854,31 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
{onToggleHidden && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.hidden
? "text-zinc-500 hover:text-zinc-600 dark:text-zinc-400"
: "text-zinc-400 hover:text-zinc-500",
)}
onClick={() => void onToggleHidden(!agent.hidden)}
disabled={toggleHiddenPending}
title={
agent.hidden
? "取消隐藏:恢复在广场等列表中展示"
: "隐藏:除本管理页外不再展示,智能体仍可正常使用"
}
>
{agent.hidden ? (
<EyeIcon className="h-3.5 w-3.5" />
) : (
<EyeOffIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
@@ -822,7 +918,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
<Button
size="sm"
className={cn(isList ? "shrink-0" : "w-full")}
onClick={handleChat}
onClick={() => void handleChat()}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
@@ -831,7 +927,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const cardLayout = (
<Card
className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"
className={cn(
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
agent.hidden && "opacity-60",
)}
data-agent-guide="agent-card"
>
<CardHeader className="min-w-0 pb-2">
@@ -892,7 +991,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
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]"
className={cn(
"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]",
agent.hidden && "opacity-60",
)}
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">
@@ -960,9 +1062,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已选 {selectedSkills.length} 个
</span>
</div>
<SkillTransferSelector
skills={skills}
selected={selectedSkills}
<SkillTransferSelector
skills={skills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
@@ -994,6 +1096,19 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
onChange={setEditCommonQuestions}
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">使用模型追加提示词</p>
<p className="text-muted-foreground text-xs">
默认开启。关闭后,该智能体的问答不会附加管理员为所选模型设置的全局提示词。
</p>
</div>
<Switch
checked={editModelPromptSuffixEnabled}
onCheckedChange={setEditModelPromptSuffixEnabled}
aria-label="使用模型追加提示词"
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">会商技能识别强化</p>
@@ -1074,6 +1189,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
}}
/>
</div>
{/* <AgentChatLinkFields value={editChatLink} onChange={setEditChatLink} /> */}
</div>
<DialogFooter>
<Button
@@ -0,0 +1,224 @@
"use client";
import { PlusIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AgentChatLink, AgentChatRouteParam } from "@/core/agents/types";
import { emptyAgentChatLink } from "@/core/agents/types";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
parseThemeMap,
stringifyThemeMap,
} from "@/strategy-components/lib/light-app";
function newParam(): AgentChatRouteParam {
return {
key: "",
paramType: "login",
source: "access_token",
customValue: "",
themeValue: null,
};
}
export function AgentChatLinkFields({
value,
onChange,
}: {
value: AgentChatLink;
onChange: (next: AgentChatLink) => void;
}) {
const link = value ?? emptyAgentChatLink();
function patch(partial: Partial<AgentChatLink>) {
onChange({ ...link, ...partial });
}
function patchParam(index: number, partial: Partial<AgentChatRouteParam>) {
patch({
route_params: link.route_params.map((param, i) =>
i === index ? { ...param, ...partial } : param,
),
});
}
return (
<div className="space-y-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">对话跳转</p>
<p className="text-muted-foreground text-xs">
配置后,智能体管理里的「对话」按钮会打开这个地址。留空则仍进入本系统问答。
</p>
</div>
<Input
value={link.url}
onChange={(event) => patch({ url: event.currentTarget.value })}
placeholder="https://... ,或以 / 开头的站内路径"
/>
<div className="space-y-1">
<p className="text-muted-foreground text-xs">打开方式</p>
<Select
value={link.open_mode}
onValueChange={(next) => patch({ open_mode: next === "self" ? "self" : "blank" })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blank">新窗口</SelectItem>
<SelectItem value="self">当前窗口</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">路由参数</p>
<p className="text-muted-foreground text-xs">
点击「对话」时拼到地址后面。登录参数取当前登录信息,主题参数跟随当前主题。
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => patch({ route_params: [...link.route_params, newParam()] })}
>
<PlusIcon className="h-3.5 w-3.5" />
添加
</Button>
</div>
{link.route_params.length === 0 ? (
<p className="text-muted-foreground rounded-md border border-dashed px-3 py-3 text-center text-xs">
暂无路由参数
</p>
) : (
<div className="space-y-2">
{link.route_params.map((param, index) => {
const themeMap = param.paramType === "theme" ? parseThemeMap(param.themeValue) : {};
return (
<div key={index} className="space-y-2 rounded-md border p-2">
<div className="flex items-center gap-2">
<Input
value={param.key}
onChange={(event) => patchParam(index, { key: event.currentTarget.value })}
placeholder="参数名"
className="h-8 font-mono text-xs"
/>
<Select
value={param.paramType}
onValueChange={(next) =>
patchParam(index, {
paramType: next === "theme" ? "theme" : "login",
source: next === "login" ? param.source || "access_token" : null,
themeValue:
next === "theme"
? param.themeValue || stringifyThemeMap({})
: null,
})
}
>
<SelectTrigger className="h-8 w-[120px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
patch({
route_params: link.route_params.filter((_, i) => i !== index),
})
}
title="移除参数"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{param.paramType === "login" && (
<div className="flex items-center gap-2">
<Select
value={param.source || "access_token"}
onValueChange={(next) => patchParam(index, { source: next })}
>
<SelectTrigger className="h-8 min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOGIN_SOURCE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{param.source === "other" && (
<Input
value={param.customValue ?? ""}
onChange={(event) =>
patchParam(index, { customValue: event.currentTarget.value })
}
placeholder="自定义值"
className="h-8 w-[140px] text-xs"
/>
)}
</div>
)}
{param.paramType === "theme" && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px]">
各系统主题色跳转时传给目标地址的值
</p>
{APP_THEME_COLORS.map((color) => (
<div key={color.key} className="flex items-center gap-2">
<span className="flex w-[72px] shrink-0 items-center gap-1.5 text-xs">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: color.dot }}
/>
{color.label}
</span>
<Input
value={themeMap[color.key] ?? color.key}
onChange={(event) =>
patchParam(index, {
themeValue: stringifyThemeMap({
...themeMap,
[color.key]: event.currentTarget.value,
}),
})
}
className="h-7 font-mono text-xs"
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import {
DatabaseIcon,
GlobeIcon,
InfoIcon,
// Link2Icon,
Loader2Icon,
LockIcon,
MessageCircleQuestionIcon,
@@ -25,6 +26,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
// import { AgentChatLinkFields } from "@/components/workspace/agents/agent-chat-link-fields";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -59,9 +61,16 @@ import {
useSetAgentFeatured,
useUpdateAgent,
type Agent,
type AgentChatLink,
type AgentQuestion,
type UpdateAgentRequest,
} from "@/core/agents";
import {
agentChatLinksEqual,
canonicalAgentChatLink,
isAllowedAgentChatUrl,
} from "@/core/agents/chat-link";
import { emptyAgentChatLink } from "@/core/agents/types";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { isAgentSkillCandidate } from "@/core/skills";
@@ -81,9 +90,11 @@ interface AgentDraft {
published: boolean;
featured: boolean;
templateName: string;
modelPromptSuffixEnabled: boolean;
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
knowledgeBaseIds: string[];
chatLink: AgentChatLink;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -96,8 +107,10 @@ function draftFromAgent(agent: Agent): AgentDraft {
published: agent.published,
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
modelPromptSuffixEnabled: agent.model_prompt_suffix_enabled ?? true,
seatSkillDirective: agent.seat_skill_directive ?? false,
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
chatLink: agent.chat_link ?? emptyAgentChatLink(),
};
}
@@ -367,8 +380,22 @@ export function AgentDetailPanel({
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (draft.templateName.trim() !== (agent.template_name ?? ""))
request.template_name = draft.templateName.trim();
if (draft.modelPromptSuffixEnabled !== (agent.model_prompt_suffix_enabled ?? true))
request.model_prompt_suffix_enabled = draft.modelPromptSuffixEnabled;
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective;
const nextChatLink = canonicalAgentChatLink(draft.chatLink);
if (nextChatLink && !isAllowedAgentChatUrl(nextChatLink.url)) {
toast.error("对话链接只支持 http(s) 地址,或以 /、#/ 开头的站内路径");
return;
}
if (draft.chatLink.route_params.some((param) => !param.key.trim()) && nextChatLink) {
toast.error("路由参数键名不可为空");
return;
}
if (!agentChatLinksEqual(nextChatLink, agent.chat_link)) {
request.chat_link = nextChatLink;
}
if (
JSON.stringify(draft.knowledgeBaseIds) !==
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
@@ -663,6 +690,13 @@ export function AgentDetailPanel({
</div>
{/* 入库模板名 — view mode shows a hint row when configured. */}
{/* {!editing && agent.chat_link?.url?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<Link2Icon className="size-3 shrink-0" />
对话跳转:{agent.chat_link.open_mode === "self" ? "当前窗口" : "新窗口"}
</div>
)} */}
{!editing && agent.template_name?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<UploadCloudIcon className="size-3 shrink-0" />
@@ -702,6 +736,21 @@ export function AgentDetailPanel({
}
variant="panel"
/>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">使用模型追加提示词</p>
<p className="text-muted-foreground text-xs">
默认开启。关闭后,该智能体的问答不会附加管理员为所选模型设置的全局提示词。
</p>
</div>
<Switch
checked={draft.modelPromptSuffixEnabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, modelPromptSuffixEnabled: checked })
}
aria-label="使用模型追加提示词"
/>
</div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">会商技能识别强化</p>
@@ -948,6 +997,13 @@ export function AgentDetailPanel({
/>
</Section>
{/* {editing && draft && (
<AgentChatLinkFields
value={draft.chatLink}
onChange={(chatLink) => setDraft({ ...draft, chatLink })}
/>
)} */}
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, RefreshCwIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import { useAgentsPaginated, usePrivateAgentsPaginated, useSyncAgentDirectory } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base";
@@ -54,6 +54,7 @@ export function AgentGallery({
const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
const syncDirectory = useSyncAgentDirectory();
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState("");
@@ -460,6 +461,33 @@ export function AgentGallery({
</p>
</div>
<div className="flex items-center gap-2">
{isAdmin && (
<Button
variant="outline"
size="sm"
title="扫描 .deer-flow/agents 目录,把目录里的智能体同步进列表"
disabled={syncDirectory.isPending}
onClick={() => {
void syncDirectory.mutateAsync().then(
(result) => {
toast.success(
result.scanned > 0
? `已扫描并同步 ${result.scanned} 个目录智能体`
: "目录里没有可同步的智能体",
);
},
(err: unknown) => {
toast.error(err instanceof Error ? err.message : "扫描智能体目录失败");
},
);
}}
>
<RefreshCwIcon
className={`mr-1.5 h-4 w-4 ${syncDirectory.isPending ? "animate-spin" : ""}`}
/>
{syncDirectory.isPending ? "扫描中…" : "重新扫描智能体目录"}
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -278,6 +278,7 @@ export function AgentGuide({
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const [appDialogOpen, setAppDialogOpen] = useState(false);
const stepsRef = useRef<GuideStep[]>([]);
const start = useCallback(() => {
@@ -317,9 +318,32 @@ export function AgentGuide({
setCurrent(-1);
}, [stage]);
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
// 先卸掉引导层,关掉后再从当前步恢复。
useEffect(() => {
if (!eligible) return;
const sync = () => {
setAppDialogOpen(
Boolean(document.querySelector('[data-slot="dialog-content"]')),
);
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [eligible]);
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
if (
!eligible ||
appDialogOpen ||
current < 0 ||
current >= stepsRef.current.length
) {
return null;
}
return (
<Guide
@@ -53,8 +53,8 @@ export function AgentManageDialog({
open,
onOpenChange,
}: AgentManageDialogProps) {
const { agents, isLoading } = useAgentSelector();
const { agents: allAgents } = useAgents();
const { agents, isLoading } = useAgentSelector({ enabled: open });
const { agents: allAgents } = useAgents({ enabled: open });
const setFeaturedMutation = useSetAgentFeatured();
const reorderMutation = useReorderFeaturedAgents();
const [pickerOpen, setPickerOpen] = useState(false);
@@ -21,7 +21,7 @@ import {
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useAgent, useAgentSelector } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { desensitize } from "@/lib/desensitize";
@@ -59,22 +59,19 @@ export function AgentSelector({
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector();
// 弹窗列表只展示管理弹窗里配置的 agent(agents);但「已选中 agent」的名称解析要更宽——
// 侧边栏「常用智能体」可以选中未配置进管理弹窗的 agent(如用户自己的智能体),
// 这些 agent 也要能在胶囊上显示名称,故额外用「我的 / 广场」列表兜底解析(缓存与侧边栏共享)。
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const { agents, isLoading } = useAgentSelector({ enabled: open });
// Resolve a remembered personal agent with a single detail lookup instead of
// preloading both complete homepage gallery pages.
const { agent: selectedAgentDetail } = useAgent(selectedAgentId, {
enabled: !!selectedAgentId,
retry: false,
});
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null;
return (
[...agents, ...mineAgents, ...squareAgents].find(
(a) => a.id === selectedAgentId,
) ?? null
);
}, [agents, mineAgents, squareAgents, selectedAgentId]);
return agents.find((a) => a.id === selectedAgentId) ?? selectedAgentDetail;
}, [agents, selectedAgentDetail, selectedAgentId]);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
@@ -362,11 +362,27 @@ export function ArtifactFileDetail({
isMock,
writeFileInFlight,
]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
const isVirtualHtmlArtifact = useMemo(() => {
if (!isWriteFileVirtual) return false;
return (writeFileLanguageHint ?? checkCodeFile(filepath).language) === "html";
}, [filepath, isWriteFileVirtual, writeFileLanguageHint]);
/** write_file 流式阶段走 tool args 文本;图片等二进制产物始终走 artifacts API;
* HTML 写完后也必须切回真实文件,因为后续渲染脚本可能在同一路径覆盖最终数据;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)同样回到真实 API(可加载 + PATCH 保存)。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
return (
isWriteFileVirtual &&
!isImageFile(filepath) &&
!editVirtualMarkdown &&
(!isVirtualHtmlArtifact || writeFileInFlight)
);
}, [
filepath,
isWriteFileVirtual,
editVirtualMarkdown,
isVirtualHtmlArtifact,
writeFileInFlight,
]);
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage =
@@ -398,11 +414,10 @@ export function ArtifactFileDetail({
refetch: refetchArtifactContent,
} = useArtifactContent({
threadId,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
// 不会被旧的内存 stub 值覆盖回退。
filepath:
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
// 流式结束后统一用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL)。HTML 可能已被后续渲染脚本覆盖;Markdown 保存后的查询缓存也需要
// 命中真实路径,不能继续读取旧的内存 stub。
filepath: useWriteFileStreaming ? filepathFromProps : filepath,
enabled: isCodeFile && !useWriteFileStreaming,
});
@@ -458,9 +473,10 @@ export function ArtifactFileDetail({
const sandboxDisplayText = useMemo(() => {
const raw = displayContent ?? "";
if (disableDesensitize) return raw;
if (language === "html") return raw;
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
}, [disableDesensitize, displayContent, isLiveStreaming, language, streamPerfEnabled]);
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
@@ -2205,6 +2221,7 @@ export function ArtifactFilePreview({
if (language === "html") {
return (
<iframe
key={htmlPreviewUrl ?? "html-preview"}
className={cn(
"size-full bg-white",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
@@ -171,7 +171,14 @@ const ChatBox: React.FC<{
const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
const { skills } = useSkills();
const hasThreadMessages = (thread.values.messages?.length ?? 0) > 0;
const { skills } = useSkills({
enabled:
referencesEnabled &&
!!threadId &&
threadId !== "new" &&
(hasThreadMessages || thread.isLoading),
});
const canScanArtifactDirectory =
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
@@ -1,12 +1,11 @@
"use client";
import { useState } from "react";
import { useTheme } from "@/contexts/ThemeContext";
import { getWelcomeBrandAsset } from "@/core/branding/brand-assets";
import { AppBrandImage } from "@/core/branding/app-brand-image";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { readRuntimeFlag } from "@/env";
import { cn } from "@/lib/utils";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
/**
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
@@ -16,11 +15,8 @@ import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desen
export function CompactWelcome({ className }: { className?: string }) {
const desensitizeText = useDisplayDesensitizer();
const { theme } = useTheme();
const brandTheme = theme;
const welcomeLogo = getWelcomeBrandAsset(brandTheme);
const [failedWelcomeLogo, setFailedWelcomeLogo] = useState<string | null>(null);
const shouldShowWelcomeLogo =
Boolean(welcomeLogo) && failedWelcomeLogo !== welcomeLogo;
const systemName = useAppSystemName("");
const welcomeTitle = systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手";
const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE");
return (
@@ -30,44 +26,24 @@ export function CompactWelcome({ className }: { className?: string }) {
className,
)}
>
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
{/* 与全量模式共用同一套系统 Logo;简洁模式仅保留欢迎区的展示容器。 */}
{/* Controlled by VITE_WELCOME_LOGO_VISIBLE in runtime-config.js. */}
{isWelcomeLogoVisible && (
<div
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
border:
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
}}
>
{shouldShowWelcomeLogo && welcomeLogo ? (
<img
src={welcomeLogo}
alt=""
aria-hidden="true"
className="size-16 object-contain"
onError={() => setFailedWelcomeLogo(welcomeLogo)}
/>
) : (
<div
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
style={{
backgroundImage:
"linear-gradient(to top right, var(--workspace-brand), var(--brand-200))",
}}
>
<span className="text-2xl font-black tracking-wide text-white italic">
{desensitizeText("CM")}
</span>
</div>
)}
</div>
<div
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
border:
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
}}
>
<AppBrandImage theme={theme} className="size-16 object-contain" />
</div>
)}
<h1 className="flex items-center justify-center text-2xl font-bold tracking-tight text-foreground/90">
{desensitizeText("欢迎使用CM助手")}
{desensitizeText(welcomeTitle)}
</h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
@@ -0,0 +1,43 @@
"use client";
import { FileSpreadsheet } from "lucide-react";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { consumerThreadMaterialsDownloadUrl } from "@/core/threads/consumer-thread-values-client";
import { useThread } from "./messages/context";
import { Tooltip } from "./tooltip";
/** Downloads the Consumer-maintained source-material workbook for this chat. */
export function ConsumerMaterialsExportTrigger({
threadId,
}: {
threadId: string;
}) {
const { thread } = useThread();
const downloadUrl = consumerThreadMaterialsDownloadUrl(threadId);
const handleDownload = useCallback(() => {
if (!downloadUrl) return;
window.open(downloadUrl, "_blank", "noopener,noreferrer");
}, [downloadUrl]);
if (thread.messages.length === 0) return null;
return (
<Tooltip
content={downloadUrl ? "导出本对话检索资料" : "未配置资料导出服务"}
>
<Button
type="button"
className="text-muted-foreground hover:text-foreground"
variant="ghost"
disabled={!downloadUrl}
onClick={handleDownload}
>
<FileSpreadsheet />
导出资料
</Button>
</Tooltip>
);
}
@@ -194,7 +194,7 @@ function ManagePromptsDialog({
const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt();
const { agents } = useAgentSelector();
const { agents } = useAgentSelector({ enabled: open });
const preferences = useUserPreferences();
const updatePreferences = useUpdateUserPreferences();
const [editingId, setEditingId] = useState<string | null>(null);
@@ -3,7 +3,6 @@
import { Bot, ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import {
SidebarGroup,
@@ -13,12 +12,10 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { useSidebarAgents } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
const MAX_FREQUENT = 3;
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
const MORE_HREF = "/page/strategy/agents";
@@ -40,33 +37,7 @@ function agentHomeHref(agentId: string): string {
*/
export function FrequentAgentsList() {
const pathname = usePathname();
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
});
const frequent = useMemo<Agent[]>(() => {
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
seen.add(agent.id);
out.push(agent);
};
// 1. 置顶组优先(够 3 个则下面我的/广场都不再补)。
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
// 2. 我的智能体补足。
mine.forEach(add);
// 3. 广场默认推荐补足。
square.forEach(add);
return out;
}, [mine, square]);
const { agents: frequent } = useSidebarAgents();
if (frequent.length === 0) {
return null;
@@ -68,7 +68,7 @@ import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { useAgentsPaginated, useSidebarAgents, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -112,9 +112,6 @@ import {
import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
// 智能体图标配色(按 id 稳定散列、分主题)抽到共享模块 ./agent-icon-color,
// 与 zzJC/zzXD 模块左栏(AgentModuleSidebar)共用,保证彩色卡片配色完全一致。
@@ -156,7 +153,7 @@ function SectionLabel({
);
}
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
type CommonAgent = Pick<Agent, "id" | "name"> & { description?: string };
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
const WX_AGENT_TYPE_MINE = "mine";
@@ -253,19 +250,9 @@ export function GeneralQaSidebar({
return !v;
});
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ──
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
// The ordinary full-page sidebar only needs three id/name rows. WX-specific
// catalog pages keep their richer, filtered result below.
const { agents: sidebarAgents } = useSidebarAgents({ enabled: !wxCommonAgents });
const {
agents: wxAgents,
total: wxTotal,
@@ -283,18 +270,8 @@ export function GeneralQaSidebar({
if (wxCommonAgents) {
return wxFetchEnabled ? wxAgents : [];
}
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
seen.add(agent.id);
out.push(agent);
};
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
mine.forEach(add);
square.forEach(add);
return out;
}, [mine, square, wxAgents, wxCommonAgents, wxFetchEnabled]);
return sidebarAgents;
}, [sidebarAgents, wxAgents, wxCommonAgents, wxFetchEnabled]);
const wxEmptyHint =
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
? "该类型暂无智能体"
@@ -641,7 +618,7 @@ export function GeneralQaSidebar({
: "text-muted-foreground",
)}
>
{agent.description ? desensitize(agent.description) : "智能体助手"}
{wxCommonAgents && agent.description ? desensitize(agent.description) : "智能体助手"}
</span>
</span>
{isActive && (
File diff suppressed because it is too large. Load diff
@@ -1,92 +1,23 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, Database, SearchCheck } from "lucide-react";
import { BookOpen } 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 { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
import {
useAssistantKnowledgeVisibility,
useUpdateAssistantKnowledgeVisibility,
} from "@/core/system-settings/hooks";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
} from "@/core/system-settings/api";
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding() {
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
}
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
export function KnowledgeProductNav() {
const location = useLocation();
const navigate = useNavigate();
const client = useQueryClient();
const assistantVisibility = useAssistantKnowledgeVisibility();
const landing = useOrdinaryKnowledgeLanding();
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
onError: (error) => toast.error(error.message),
});
const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
const isAdmin = getStoredAuth()?.system_role === "admin";
const assistantAllowed = canAccessAssistantKnowledge(
isAdmin,
assistantVisibility.isError ? undefined : assistantVisibility.data,
);
const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
const assistant = location.pathname.includes("/knowledge/assistant");
const ordinary = !assistant && !retrievalDebug;
const ordinary = !location.pathname.includes("/knowledge/assistant") && !location.pathname.includes("/knowledge/retrieval-debug");
return (
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
{assistantAllowed && (
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
)}
{isAdmin && (
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
<SearchCheck />检索调试
</Button>
)}
{isAdmin && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select>
助手知识库日常展示
<Select
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
value={assistantVisibility.data === true ? "visible" : "hidden"}
onValueChange={(value) =>
saveAssistantVisibility.mutate(value === "visible", {
onSuccess: (visible) => toast.success(visible ? "已向普通用户开放助手知识库" : "已隐藏普通用户的助手知识库"),
onError: (error) => toast.error(error.message),
})
}
>
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
</Select>
</div>
)}
</div>
);
}
@@ -22,6 +22,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
import { cn } from "@/lib/utils";
import {
type AssistantKnowledgeBase,
@@ -46,14 +48,14 @@ export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
const name = cleanNames[0]!;
return [
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
`「${name}」包含哪些核心主题?请按重要性说明。`,
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
];
}
return [
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
"所选知识空间中有哪些相互冲突或证据不足的结论?",
];
@@ -426,10 +428,13 @@ export function LlmWikiKnowledgeSelector({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const assistantAccess = useAssistantKnowledgeAccess({
enabled: onAssistantChange !== undefined,
enabled:
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
});
const assistantAllowed =
onAssistantChange !== undefined && assistantAccess.canAccess;
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
onAssistantChange !== undefined &&
assistantAccess.canAccess;
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
@@ -489,7 +494,12 @@ export function LlmWikiKnowledgeSelector({
[selectedAssistantNames, selectedNames],
);
const selectedConfiguredQuestions = useMemo(
() => value.flatMap((id) => rows.find((item) => item.id === id)?.test_questions ?? []),
() =>
userFacingKnowledgeSuggestedQuestions(
value.flatMap(
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
),
),
[rows, value],
);
const suggestedQuestions = useMemo(
@@ -549,7 +559,11 @@ export function LlmWikiKnowledgeSelector({
value={value}
assistantValue={effectiveAssistantValue}
onChange={onChange}
onAssistantChange={onAssistantChange ?? IGNORE_ASSISTANT_CHANGE}
onAssistantChange={
assistantAllowed
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
: IGNORE_ASSISTANT_CHANGE
}
search={search}
onSearchChange={setSearch}
loading={loading}
@@ -32,21 +32,21 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
@@ -57,9 +57,11 @@ import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import {
applyInlineCitations,
@@ -83,22 +85,26 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
export function MessageGroup({
className,
messages,
isLoading = false,
displaySources = [],
ragModeEnabled = false,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
export function MessageGroup({
className,
messages,
isLoading = false,
displaySources = [],
ragModeEnabled = false,
threadId,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
threadId?: string;
}) {
const { t } = useI18n();
const { data: citationDisplay } = useCitationDisplaySettings();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
@@ -113,10 +119,20 @@ export function MessageGroup({
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
const { skills } = useSkills({
enabled:
messages.length > 0 &&
(ragModeEnabled || citationDisplay?.retrieved_files_enabled === true),
});
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
@@ -211,7 +227,8 @@ export function MessageGroup({
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
),
)}
@@ -223,7 +240,8 @@ export function MessageGroup({
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
)}
@@ -272,7 +290,8 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
<SingleRoundRagCitationDrawer
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
@@ -337,6 +356,7 @@ function ToolCall({
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false,
}: {
id?: string;
@@ -347,6 +367,7 @@ function ToolCall({
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
@@ -357,19 +378,42 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
? syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}),
),
),
displaySources,
)
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const isLlmWikiTool =
name === "llmwiki_search" ||
name === "llmwiki_read_wiki_page" ||
name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool
? resolveSearchQuery(args, result) ||
(name === "llmwiki_read_wiki_page"
? normalizeSearchResults(result)[0]?.title ||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
: "")
: "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
@@ -395,12 +439,17 @@ function ToolCall({
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}));
const defaultItems = syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
})),
),
displaySources,
);
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
@@ -782,10 +831,10 @@ function ArtifactWriteToolCall({
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Until a real path arrives, both auto-open and manual clicks stay inline.
// Until a real path arrives, both auto-open and manual clicks stay inline.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
@@ -797,10 +846,10 @@ function ArtifactWriteToolCall({
!artifactPath ||
!previewKey ||
!isLoading ||
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
) {
return;
}
@@ -826,9 +875,9 @@ function ArtifactWriteToolCall({
artifacts.setOpen(true);
}}
>
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
@@ -837,29 +886,29 @@ function ArtifactWriteToolCall({
);
}
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> {
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
@@ -879,6 +928,10 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
// per-name cursor so parallel searches consume distinct unbound results in
// order instead of every step reusing the first page.
const toolCallNameFallbackIndexes = new Map<string, number>();
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
@@ -891,37 +944,37 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
};
steps.push(step);
}
const writes = new Map(
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
const writes = new Map(
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
continue;
}
const write = tool_call.name === "write_file" && tool_call.id
? writes.get(tool_call.id)
: undefined;
const args = write
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.path : "";
const write = tool_call.name === "write_file" && tool_call.id
? writes.get(tool_call.id)
: undefined;
const args = write
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.path : "";
if (
(
tool_call.name === "write_file" ||
@@ -939,15 +992,27 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args,
args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(
let toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: false },
);
if (toolCallResult === undefined) {
const nameKey = tool_call.name.toLowerCase();
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
);
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
}
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
@@ -1014,6 +1079,48 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
);
}
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
return citationId ? [[citationId, source.index] as const] : [];
}),
);
return sources.map((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
const index = citationId ? indexByCitationId.get(citationId) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
@@ -1051,46 +1158,46 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
</div>
)}
</button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
</>
);
}
@@ -17,6 +17,12 @@ import {
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import {
INITIAL_VISIBLE_HUMAN_TURNS,
MORE_VISIBLE_HUMAN_TURNS,
REVEAL_CHAT_HISTORY_EVENT,
sliceMessagesByRecentHumanTurns,
} from "@/core/messages/history-window";
import {
extractContentFromMessage,
extractPresentFilesFromMessage,
@@ -32,6 +38,8 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { useRetryKeepPartialSettings } from "@/core/system-settings/hooks";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -40,22 +48,35 @@ import {
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import {
shouldRenderUnattachedKnowledgeRetrievalProgress,
useKnowledgeRetrievalProgress,
} from "@/core/threads/knowledge-retrieval-progress";
import {
buildSmartKnowledgeRetrievalDisplaySegments,
collectSmartKnowledgeRetrievalTurnAssistantMessageIds,
hasSmartKnowledgeRetrievalCalls,
latestSmartKnowledgeRetrievalMessageId,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
import {
MessageMetricsProvider,
MessageTokenUsageList,
@@ -67,7 +88,7 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({
function ScrollToBottomOnUserMessage({
threadId,
messages,
}: {
@@ -111,54 +132,54 @@ function ScrollToBottomOnUserMessage({
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null;
}
/**
* Exposes the conversation's scroll position to hosts that render controls
* outside the scroll container (for example the docked follow-up questions).
*/
function ConversationBottomObserver({
onAtBottomChange,
}: {
onAtBottomChange?: (isAtBottom: boolean) => void;
}) {
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const update = () => {
const distance =
scrollElement.scrollHeight -
scrollElement.scrollTop -
scrollElement.clientHeight;
setIsExactlyAtBottom(distance <= 2);
};
update();
scrollElement.addEventListener("scroll", update, { passive: true });
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
resizeObserver?.observe(scrollElement);
if (contentRef.current) resizeObserver?.observe(contentRef.current);
return () => {
scrollElement.removeEventListener("scroll", update);
resizeObserver?.disconnect();
};
}, [contentRef, scrollRef]);
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
useEffect(() => {
onAtBottomChange?.(displayControlsAtBottom);
}, [displayControlsAtBottom, onAtBottomChange]);
return null;
}
export function MessageList({
return null;
}
/**
* Exposes the conversation's scroll position to hosts that render controls
* outside the scroll container (for example the docked follow-up questions).
*/
function ConversationBottomObserver({
onAtBottomChange,
}: {
onAtBottomChange?: (isAtBottom: boolean) => void;
}) {
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const update = () => {
const distance =
scrollElement.scrollHeight -
scrollElement.scrollTop -
scrollElement.clientHeight;
setIsExactlyAtBottom(distance <= 2);
};
update();
scrollElement.addEventListener("scroll", update, { passive: true });
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
resizeObserver?.observe(scrollElement);
if (contentRef.current) resizeObserver?.observe(contentRef.current);
return () => {
scrollElement.removeEventListener("scroll", update);
resizeObserver?.disconnect();
};
}, [contentRef, scrollRef]);
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
useEffect(() => {
onAtBottomChange?.(displayControlsAtBottom);
}, [displayControlsAtBottom, onAtBottomChange]);
return null;
}
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
@@ -166,10 +187,10 @@ export function MessageList({
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onAtBottomChange,
onClarificationSubmit,
extraRehypePlugins,
scrollButtonClassName,
onAtBottomChange,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
@@ -186,10 +207,10 @@ export function MessageList({
businessCardSlot,
speakerShell,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
answerFeedbackEnabled = false,
}: {
onDownloadArtifact,
afterDocumentRewriteItems,
answerFeedbackEnabled = false,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -197,10 +218,10 @@ export function MessageList({
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
/** Reports whether the message viewport is currently at its bottom edge. */
onAtBottomChange?: (isAtBottom: boolean) => void;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
/** Reports whether the message viewport is currently at its bottom edge. */
onAtBottomChange?: (isAtBottom: boolean) => void;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
@@ -244,15 +265,15 @@ export function MessageList({
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
/**
* Enables per-answer user feedback for persisted assistant replies. Kept
* opt-in because MessageList is also reused by read-only previews, setup
* flows and virtual business-card feeds.
*/
answerFeedbackEnabled?: boolean;
}) {
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
/**
* Enables per-answer user feedback for persisted assistant replies. Kept
* opt-in because MessageList is also reused by read-only previews, setup
* flows and virtual business-card feeds.
*/
answerFeedbackEnabled?: boolean;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -273,60 +294,76 @@ export function MessageList({
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
() => new Set(),
);
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
item: SmartKnowledgeRetrievalResultItem;
target: WeKnoraSourceTarget;
} | null>(null);
const [visibleHumanTurns, setVisibleHumanTurns] = useState(
INITIAL_VISIBLE_HUMAN_TURNS,
);
useEffect(() => {
setHiddenMessageIds(new Set());
setSelectedRetrievalResult(null);
setVisibleHumanTurns(INITIAL_VISIBLE_HUMAN_TURNS);
}, [threadId]);
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
useEffect(() => {
const reveal = () => {
setVisibleHumanTurns(Number.MAX_SAFE_INTEGER);
};
window.addEventListener(REVEAL_CHAT_HISTORY_EVENT, reveal);
return () => window.removeEventListener(REVEAL_CHAT_HISTORY_EVENT, reveal);
}, []);
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
if (deletedIds.length === 0) return;
setHiddenMessageIds((prev) => {
const next = new Set(prev);
for (const id of deletedIds) next.add(id);
return next;
});
}, []);
const documentRewriteItems = useMemo(
() => documentRewritePresentation?.forThread(threadId) ?? [],
[documentRewritePresentation, threadId],
);
const artifactRewriteItems = useMemo(
() => documentRewriteItems.filter(
(item) => item.source.kind === "artifact" && item.operation === "rewrite",
),
[documentRewriteItems],
);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
const persistedMessages = thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
});
}, []);
const documentRewriteItems = useMemo(
() => documentRewritePresentation?.forThread(threadId) ?? [],
[documentRewritePresentation, threadId],
);
const artifactRewriteItems = useMemo(
() => documentRewriteItems.filter(
(item) => item.source.kind === "artifact" && item.operation === "rewrite",
),
[documentRewriteItems],
);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
const persistedMessages = thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
const rewriteMessages = artifactRewriteItems.flatMap((item) => [
{
id: `document-rewrite-input-${item.id}`,
type: "human",
content: "AI改写",
} as unknown as Message,
{
id: `document-rewrite-${item.id}`,
type: "ai",
content: "",
additional_kwargs: {
document_rewrite_presentation_id: item.id,
},
} as unknown as Message,
]);
return [...persistedMessages, ...rewriteMessages];
}, [artifactRewriteItems, messageContentOverrides, thread.messages]);
const messages = useMemo(
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
const rewriteMessages = artifactRewriteItems.flatMap((item) => [
{
id: `document-rewrite-input-${item.id}`,
type: "human",
content: "AI改写",
} as unknown as Message,
{
id: `document-rewrite-${item.id}`,
type: "ai",
content: "",
additional_kwargs: {
document_rewrite_presentation_id: item.id,
},
} as unknown as Message,
]);
return [...persistedMessages, ...rewriteMessages];
}, [artifactRewriteItems, messageContentOverrides, thread.messages]);
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -338,41 +375,62 @@ export function MessageList({
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
const answerFeedbackMessageIds = useMemo(() => {
if (!feedbackAvailable) return [];
const streamingAssistantId = thread.isLoading
? [...messages].reverse().find((message) => message.type === "ai")?.id
: undefined;
return messages.flatMap((message) => {
const kwargs = message.additional_kwargs ?? {};
const explicitlyStreaming =
kwargs.deep_research_summary_streaming === true ||
kwargs.deep_research_follow_up_streaming === true;
const loading =
explicitlyStreaming ||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
? [message.id]
: [];
});
}, [feedbackAvailable, messages, thread.isLoading]);
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
threadId,
answerFeedbackMessageIds,
feedbackAvailable,
);
const ratableAnswerMessageIds = useMemo(() => {
const lastMessageByRun = new Map<string, string>();
for (const message of messages) {
if (!message.id) continue;
const context = answerFeedbackContexts?.[message.id];
if (context) lastMessageByRun.set(context.run_id, message.id);
}
return new Set(lastMessageByRun.values());
}, [answerFeedbackContexts, messages]);
const askTimes = useChatAskTimes();
);
const { messages: displayMessages, hiddenHumanTurns } = useMemo(
() => sliceMessagesByRecentHumanTurns(messages, visibleHumanTurns),
[messages, visibleHumanTurns],
);
const knowledgeRetrievalProgress = useKnowledgeRetrievalProgress(threadId);
const hasHumanMessage = messages.some((message) => message.type === "human");
const currentTurnMessages = useMemo(() => {
let lastHumanIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.type === "human") {
lastHumanIndex = index;
break;
}
}
return messages.slice(lastHumanIndex + 1);
}, [messages]);
const latestCurrentSmartMessageId = useMemo(
() => latestSmartKnowledgeRetrievalMessageId(currentTurnMessages),
[currentTurnMessages],
);
const lastCurrentTurnMessageId = currentTurnMessages.at(-1)?.id;
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
const answerFeedbackMessageIds = useMemo(() => {
if (!feedbackAvailable) return [];
const streamingAssistantId = thread.isLoading
? [...messages].reverse().find((message) => message.type === "ai")?.id
: undefined;
return messages.flatMap((message) => {
const kwargs = message.additional_kwargs ?? {};
const explicitlyStreaming =
kwargs.deep_research_summary_streaming === true ||
kwargs.deep_research_follow_up_streaming === true;
const loading =
explicitlyStreaming ||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
? [message.id]
: [];
});
}, [feedbackAvailable, messages, thread.isLoading]);
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
threadId,
answerFeedbackMessageIds,
feedbackAvailable,
);
const ratableAnswerMessageIds = useMemo(() => {
const lastMessageByRun = new Map<string, string>();
for (const message of messages) {
if (!message.id) continue;
const context = answerFeedbackContexts?.[message.id];
if (context) lastMessageByRun.set(context.run_id, message.id);
}
return new Set(lastMessageByRun.values());
}, [answerFeedbackContexts, messages]);
const askTimes = useChatAskTimes();
// Stamp a wall-clock time onto every *question* the first time it is seen, in
// a single batched commit. Questions sent this session already carry an
@@ -432,11 +490,15 @@ export function MessageList({
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
// 「保留中间正文」管理员开关(默认开启):步骤条执行中吐出的正文不被清空,
// 模型静默重试时已流出的半截正文同样保留(后者在 useAgentThread 里处理)。
const { data: retryKeepPartialSettings } = useRetryKeepPartialSettings();
const keepIntermediateText = retryKeepPartialSettings?.enabled ?? true;
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true ||
!!threadContext?.knowledgeReferences;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true ||
!!threadContext?.knowledgeReferences;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
@@ -446,7 +508,11 @@ export function MessageList({
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const { skills } = useSkills();
const { skills } = useSkills({
enabled:
messages.length > 0 &&
(ragModeEnabled || citationDisplay?.retrieved_files_enabled === true),
});
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
[referencesEnabled, skills],
@@ -498,10 +564,29 @@ export function MessageList({
return map;
}, [thread.messages]);
const smartRetrievalAssistantMessageIds = useMemo(() => {
return collectSmartKnowledgeRetrievalTurnAssistantMessageIds(messages);
}, [messages]);
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
if (isMultiRoundRagMessage(msg)) return [];
const previousHumanId = findPreviousHumanMessageId(messages, msg.id);
// Smart retrieval has its own clickable evidence trace and explicitly
// does not require numbered references. Do not feed the same transport
// results into the legacy citation layer, which would append [1][2] to
// an otherwise complete answer.
const isSmartRetrievalTurn = msg.id
? smartRetrievalAssistantMessageIds.has(msg.id)
: hasSmartKnowledgeRetrievalCalls(
messages.slice(
previousHumanId
? messages.findIndex((message) => message.id === previousHumanId) + 1
: 0,
),
);
if (isSmartRetrievalTurn) return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
@@ -511,7 +596,7 @@ export function MessageList({
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
afterMessageId: previousHumanId,
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
@@ -522,6 +607,7 @@ export function MessageList({
referenceBatches,
referencesEnabled,
skillDisplayMap,
smartRetrievalAssistantMessageIds,
],
);
@@ -637,20 +723,40 @@ export function MessageList({
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
<ConversationContent
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{(() => {
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{hiddenHumanTurns > 0 ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
className="text-muted-foreground h-8 rounded-full px-3 text-xs"
onClick={() =>
setVisibleHumanTurns(
(current) => current + MORE_VISIBLE_HUMAN_TURNS,
)
}
>
查看更早的对话(还有 {hiddenHumanTurns} 轮)
</Button>
</div>
) : null}
{(() => {
let didRenderLiveKnowledgeRetrieval = false;
const grouped = groupMessages(
displayMessages,
(group) => {
if (group.type === "human" || group.type === "assistant") {
const items = group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
@@ -693,14 +799,14 @@ export function MessageList({
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
answerFeedbackEnabled={answerFeedbackEnabled}
answerFeedbackContext={
msg.id && ratableAnswerMessageIds.has(msg.id)
? answerFeedbackContexts?.[msg.id]
: undefined
}
/>
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
answerFeedbackEnabled={answerFeedbackEnabled}
answerFeedbackContext={
msg.id && ratableAnswerMessageIds.has(msg.id)
? answerFeedbackContexts?.[msg.id]
: undefined
}
/>
);
});
if (speakerShell && group.type === "assistant") {
@@ -826,30 +932,30 @@ export function MessageList({
{taskReportImportSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:business-card") {
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return businessCardSlot && message ? (
<div key={group.id} className="w-full pb-2">
{businessCardSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:document-rewrite") {
const presentationId = group.messages[0]?.additional_kwargs?.document_rewrite_presentation_id;
const item = typeof presentationId === "string"
? artifactRewriteItems.find((candidate) => candidate.id === presentationId)
: undefined;
return item ? (
<div className="w-full" key={group.id}>
<DocumentRewriteMessage
item={item}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
} else if (group.type === "assistant:business-card") {
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return businessCardSlot && message ? (
<div key={group.id} className="w-full pb-2">
{businessCardSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:document-rewrite") {
const presentationId = group.messages[0]?.additional_kwargs?.document_rewrite_presentation_id;
const item = typeof presentationId === "string"
? artifactRewriteItems.find((candidate) => candidate.id === presentationId)
: undefined;
return item ? (
<div className="w-full" key={group.id}>
<DocumentRewriteMessage
item={item}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
if (hasPresentFiles(message)) {
@@ -857,9 +963,9 @@ export function MessageList({
files.push(...presentFiles);
}
}
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
@@ -938,6 +1044,7 @@ export function MessageList({
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
threadId={threadId}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
@@ -988,14 +1095,95 @@ export function MessageList({
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
const hasSmartRetrievalCalls = hasSmartKnowledgeRetrievalCalls(
group.messages,
);
const isCurrentTurnRetrievalGroup = group.messages.some(
(message) =>
message.id ===
(latestCurrentSmartMessageId ?? lastCurrentTurnMessageId),
);
const receivesLiveRetrievalProgress =
knowledgeRetrievalProgress.length > 0 &&
isCurrentTurnRetrievalGroup;
const retrievalDisplaySegments =
hasSmartRetrievalCalls || receivesLiveRetrievalProgress
? buildSmartKnowledgeRetrievalDisplaySegments(
group.messages,
receivesLiveRetrievalProgress
? knowledgeRetrievalProgress
: [],
)
: [
{
kind: "processing" as const,
id: `processing-${group.id ?? "group"}`,
messages: group.messages,
},
];
if (
retrievalDisplaySegments.some(
(segment) =>
segment.kind === "retrieval" &&
segment.progressFrames.length > 0,
)
) {
didRenderLiveKnowledgeRetrieval = true;
}
const processingBody = (
<div className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/>
<div className="w-full space-y-2">
{retrievalDisplaySegments.map((segment) => {
if (segment.kind === "retrieval") {
return (
<SmartKnowledgeRetrievalTrace
key={segment.id}
messages={segment.messages}
progressFrames={segment.progressFrames}
dedupeScopeMessages={group.messages}
dedupeScopeProgressFrames={
receivesLiveRetrievalProgress
? knowledgeRetrievalProgress
: []
}
roundOffset={segment.roundOffset}
// The AI tool-call message can arrive before its custom
// `started` frame. Keep the current turn active through
// that short gap instead of misclassifying the pending
// call as an interrupted historical action.
isLoading={
isCurrentTurnRetrievalGroup && thread.isLoading
}
onOpenResult={(item, target) =>
setSelectedRetrievalResult({ item, target })
}
/>
);
}
const segmentHasVisibleProcessing = segment.messages.some(
(message) =>
message.type === "tool" ||
(message.type === "ai" &&
(hasReasoning(message) ||
hasContent(message) ||
hasToolCalls(message))),
);
if (!segmentHasVisibleProcessing) return null;
const segmentAi = [...segment.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<MessageGroup
key={segment.id}
threadId={threadId}
messages={segment.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(
segmentAi ?? processingAi,
)}
ragModeEnabled={ragModeEnabled}
/>
);
})}
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -1008,13 +1196,28 @@ export function MessageList({
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
</div>
);
});
const leftoverRewriteItems = documentRewriteItems.filter(
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
);
}, { keepIntermediateText });
const leftoverRewriteItems = documentRewriteItems.filter(
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
);
return (
<>
{grouped}
{shouldRenderUnattachedKnowledgeRetrievalProgress({
frameCount: knowledgeRetrievalProgress.length,
isLoading: thread.isLoading,
hasHumanMessage,
}) &&
!didRenderLiveKnowledgeRetrieval && (
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={knowledgeRetrievalProgress}
isLoading={thread.isLoading}
onOpenResult={(item, target) =>
setSelectedRetrievalResult({ item, target })
}
/>
)}
{leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
@@ -1036,6 +1239,16 @@ export function MessageList({
aria-label="回到最新"
title="回到最新"
/>
<WeKnoraSourceDrawer
open={selectedRetrievalResult !== null}
target={selectedRetrievalResult?.target ?? null}
title={selectedRetrievalResult?.item.title}
quote={selectedRetrievalResult?.item.preview ?? ""}
collapseRetrievedExcerptByDefault
onOpenChange={(open) => {
if (!open) setSelectedRetrievalResult(null);
}}
/>
</Conversation>
);
}
@@ -1079,9 +1292,9 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
kind?: "report_structure" | "gongwen_template";
title?: string;
export type SetupReportStructureApprovalPayload = {
kind?: "report_structure" | "gongwen_template";
title?: string;
name?: string;
content?: string;
structure_mode?: string;
@@ -1184,8 +1397,8 @@ function SetupAgentApprovalCard({
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") {
@@ -1481,7 +1694,7 @@ const STRUCTURE_MODE_LABELS: Record<string, string> = {
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
@@ -1499,8 +1712,8 @@ function SetupReportStructureApprovalCard({
return null;
}
const approvalPayload = payload;
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
const approvalPayload = payload;
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
@@ -1525,8 +1738,8 @@ function SetupReportStructureApprovalCard({
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
</div>
{approvalPayload.title ? (
@@ -1568,7 +1781,7 @@ function SetupReportStructureApprovalCard({
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
@@ -0,0 +1,207 @@
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
import { useContext, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { urlOfArtifact } from "@/core/artifacts/utils";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RetrievedFile } from "@/core/messages/retrieved-files";
import { getFileIcon } from "@/core/utils/files";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiSourceContext,
resolveLlmWikiSourceKnowledgeBase,
} from "@/strategy-components/api/llmwiki";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ThreadContext } from "./context";
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
// File links belong to the upstream service. Never attach DeerFlow auth headers.
const fetchExternalFile: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "omit" });
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
const [expanded, setExpanded] = useState(false);
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
// Results arrive in retrieval order; keep the newest file outside the fold.
const latest = visible.at(-1);
if (!latest) return null;
const older = visible.slice(0, -1).reverse();
return (
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
{older.length > 0 && (
<CollapsibleTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
{expanded ? "收起" : "展开"}
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
)}
</div>
<ul className="grid gap-2">
<RetrievedFileCard
key={latest.key}
file={latest}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
</ul>
<CollapsibleContent>
<ul className="grid gap-2">
{older.map((file) => (
<RetrievedFileCard
key={file.key}
file={file}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
))}
</ul>
</CollapsibleContent>
</section>
</Collapsible>
);
}
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
file: RetrievedFile;
threadId: string;
isMock?: boolean;
publicShareCode?: string;
}) {
const [previewOpen, setPreviewOpen] = useState(false);
const [preparingPreview, setPreparingPreview] = useState(false);
const [downloading, setDownloading] = useState(false);
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
const fileUrl = file.kind === "artifact"
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
: file.kind === "external" ? file.url : "";
const resolveKnowledgeTarget = async () => {
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
if (knowledgeTarget) return knowledgeTarget;
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
const target = { ...file.target, knowledgeBaseId };
setKnowledgeTarget(target);
return target;
};
const preview = async () => {
if (preparingPreview) return;
setPreparingPreview(true);
try {
if (file.kind === "knowledge") await resolveKnowledgeTarget();
setPreviewOpen(true);
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件预览失败");
} finally {
setPreparingPreview(false);
}
};
const download = async () => {
if (downloading || file.kind === "external") return;
setDownloading(true);
try {
if (file.kind === "artifact") {
await downloadArtifactFile({
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
});
} else {
const target = await resolveKnowledgeTarget();
let documentId = target.documentId;
if (!documentId && target.chunkId) {
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
documentId = context.document_id || undefined;
}
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
const [blob, document] = await Promise.all([
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
]);
const objectUrl = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = objectUrl;
anchor.download = document?.file_name || file.name;
anchor.rel = "noopener";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件下载失败");
} finally {
setDownloading(false);
}
};
return (
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium break-words">{file.name}</div>
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{file.kind === "external" && file.previewUrl ? (
<Button asChild size="sm" variant="ghost">
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
<EyeIcon className="size-4" />预览
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
</Button>
)}
{file.kind === "external" ? (
<Button asChild size="sm" variant="ghost">
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
<DownloadIcon className="size-4" />下载
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
</Button>
)}
</div>
{previewOpen && (file.kind === "knowledge" ? (
<WeKnoraSourceDrawer
open
target={knowledgeTarget || file.target}
title={file.name}
quote={file.quote || ""}
onOpenChange={setPreviewOpen}
/>
) : (
<UploadedFilePreviewDialog
filename={file.name}
fileUrl={fileUrl}
open
onOpenChange={setPreviewOpen}
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
virtualPath={file.kind === "artifact" ? file.path : undefined}
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
onDownload={file.kind === "artifact" ? download : undefined}
downloading={downloading}
/>
))}
</li>
);
}
@@ -0,0 +1,406 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { Message } from "@langchain/langgraph-sdk";
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "started",
queries: ["成都世运会"],
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 3,
elapsed_ms: 420,
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "started",
pattern: "火炬|开幕|闭幕",
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 4,
elapsed_ms: 1380,
},
];
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading={false}
/>,
);
assert.match(html, /aria-label="展开智能检索过程"/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
assert.match(html, /累计耗时 1\.8 秒/);
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
});
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
const messages = [
{
type: "ai",
id: "ai-pending-wiki",
content: "",
tool_calls: [
{
id: "wiki-pending",
name: "ordinary_wiki_search",
args: { pattern: "数字中国|数据要素" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /检索中/);
assert.match(html, /正在查 Wiki/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
});
test("labels and normalizes a visible model-authored regex", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[
{
type: "ai",
id: "ai-regex",
content: "",
tool_calls: [
{
id: "wiki-regex",
name: "ordinary_wiki_search",
args: { pattern: "^# [一-龥]{2,10}篇�" },
},
],
},
] as unknown as Message[]}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
assert.doesNotMatch(html, /一-龥|�/);
});
test("presents an interrupted historical action as neutral instead of failed", () => {
const messages = [
{
type: "ai",
id: "ai-interrupted-wiki",
content: "",
tool_calls: [
{
id: "wiki-interrupted",
name: "ordinary_wiki_search",
args: { pattern: "数字中国" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading={false}
/>,
);
assert.match(html, /检索已结束(部分步骤未完成)/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
});
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索中/);
assert.match(html, /成都世运会/);
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
});
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索完成/);
assert.match(html, /aria-label="收起智能检索过程"/);
assert.match(html, /检索 Wiki/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
assert.doesNotMatch(html, /检索中/);
});
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
roundOffset={2}
/>,
);
assert.match(html, /检索原始文档/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
});
test("renders stable retrieval results as right-drawer controls", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-news",
slug: "news/opening",
title: "开幕新闻 Wiki",
preview: "页面摘要",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /data-retrieval-result-detail="true"/);
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
});
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_read_page",
status: "completed",
result_count: 1,
knowledge_base_count: 1,
coverage: [
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
],
elapsed_ms: 1260,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-digital-china",
knowledge_base: "数字中国政策目录智能检索远端验收",
slug: "digital-china/timeline",
page_ref: "wiki-page:v1:digital-china-timeline",
title: "数字中国建设时间线",
content_markdown:
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /读取结果/);
assert.match(html, /数字中国建设时间线/);
assert.match(html, /页面正文概览,包含建设目标/);
assert.match(html, /右侧查看/);
assert.match(html, /耗时 1\.3 秒/);
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
assert.doesNotMatch(html, /知识库知识库/);
assert.match(html, /data-retrieval-result-detail="true"/);
});
test("shows all usable document-read results without a generic partial warning", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "document_chunk_read",
status: "completed",
result_count: 20,
partial: true,
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
message: "原文上下文读取完成,找到 20 条结果",
result_items: Array.from({ length: 20 }, (_, index) => ({
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_base: "国内新闻",
knowledge_id: "document-long-news",
chunk_id: `chunk-${index + 1}`,
title: `长篇新闻第 ${index + 1} 段`,
content: `原文上下文第 ${index + 1} 段`,
})),
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
20,
);
assert.match(html, /长篇新闻第 1 段/);
assert.match(html, /长篇新闻第 20 段/);
assert.doesNotMatch(html, /部分数据源返回警告/);
assert.doesNotMatch(html, /部分完成/);
});
test("keeps historical results without stable ids as non-clickable summaries", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base: "旧知识库",
title: "旧检索结果",
},
],
},
]}
isLoading
/>,
);
assert.match(html, /data-retrieval-result-summary="true"/);
assert.match(html, /该历史检索结果缺少稳定详情标识/);
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
});
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
const firstFrame: KnowledgeRetrievalProgressFrame = {
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
retrieval_id: "raw-first",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-1",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
...firstFrame,
retrieval_id: "raw-second",
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-provider-remapped",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const firstHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[firstFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
const repeatedHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[repeatedFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
1,
);
assert.equal(
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
0,
);
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
});
@@ -0,0 +1,525 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
AlertCircleIcon,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
CirclePauseIcon,
DatabaseIcon,
Loader2Icon,
PanelRightOpenIcon,
SearchCheckIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
buildSmartKnowledgeRetrievalRounds,
type KnowledgeRetrievalProgressFrame,
type SmartKnowledgeRetrievalAction,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { cn } from "@/lib/utils";
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
if (action.status === "failed") return "检索失败";
if (action.status === "interrupted") return "已中止";
if (action.status === "running") {
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
}
if (action.allResultsAlreadyShown) return "均为已展示结果";
const hasUsableResults =
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
if (action.kind === "search") {
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
}
return typeof action.resultCount === "number"
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
: `${partialPrefix}读取完成`;
}
function formatElapsedTime(elapsedMs: number): string {
const normalized = Math.max(0, elapsedMs);
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
if (normalized < 60_000) {
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
return `${seconds} 秒`;
}
const roundedSeconds = Math.round(normalized / 1_000);
const minutes = Math.floor(roundedSeconds / 60);
const seconds = roundedSeconds % 60;
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
}
function channelText(channel: string | undefined): string | null {
if (!channel) return null;
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
if (channel === "provider") return "知识库 Wiki";
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
return channel;
}
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
if (action.status === "running") {
return <Loader2Icon className="size-4 animate-spin text-primary" />;
}
if (action.status === "completed") {
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
}
if (action.status === "failed") {
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
}
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
}
function resultDetailTarget(
item: SmartKnowledgeRetrievalResultItem,
): WeKnoraSourceTarget | null {
if (!item.knowledgeBaseId) return null;
if (item.kind === "wiki" && item.wikiSlug) {
return {
knowledgeBaseId: item.knowledgeBaseId,
wikiSlug: item.wikiSlug,
tab: "wiki",
};
}
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
return {
knowledgeBaseId: item.knowledgeBaseId,
chunkId: item.chunkId,
documentId: item.documentId,
tab: "documents",
};
}
return null;
}
function RetrievalAction({
action,
onOpenResult,
}: {
action: SmartKnowledgeRetrievalAction;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
const channel = channelText(action.channel);
return (
<div
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
data-retrieval-operation={action.operation}
>
<div className="flex min-w-0 items-start gap-2.5">
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">{action.label}</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs",
action.status === "running"
? "text-primary"
: action.status === "completed"
? "text-emerald-700 dark:text-emerald-300"
: action.status === "failed"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground",
)}
>
<ActionStatusIcon action={action} />
{actionStatusText(action)}
</span>
</div>
{action.queries.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-muted-foreground">查询</span>
{action.queries.map((query) => (
<span
key={query}
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
>
{query}
</span>
))}
</div>
)}
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{typeof action.knowledgeBaseCount === "number" ? (
<span>
覆盖 {action.knowledgeBaseCount} 个知识库
{action.knowledgeBases.length > 0
? `:${action.knowledgeBases.join("、")}`
: ""}
</span>
) : action.knowledgeBases.length > 0 ? (
<span>知识库:{action.knowledgeBases.join("、")}</span>
) : null}
{typeof action.scannedPages === "number" && (
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
)}
{typeof action.scannedChunks === "number" && (
<span>扫描 {action.scannedChunks} 个原文片段</span>
)}
{action.kind === "read" && typeof action.total === "number" && (
<span>共 {action.total} 个片段</span>
)}
{channel && <span>通道:{channel}</span>}
{typeof action.elapsedMs === "number" && (
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
)}
</div>
{action.allResultsAlreadyShown ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
本次查询结果均已在前面展示
</p>
) : action.resultItems.length > 0 ? (
<div className="mt-2">
<div className="mb-1.5 text-xs text-muted-foreground">
{action.kind === "read" ? "读取结果" : "查询结果"}
</div>
{action.kind === "read" ? (
<div className="space-y-2">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
const preview = item.preview?.trim().slice(0, 600);
const content = (
<>
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground">
{item.title}
</span>
{target && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
右侧查看
<PanelRightOpenIcon className="size-3.5" />
</span>
)}
</div>
{preview && (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{preview}
</p>
)}
</>
);
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
{content}
</button>
) : (
<div
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
title="该历史检索结果缺少稳定详情标识"
>
{content}
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
<span className="truncate">{item.title}</span>
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
</button>
) : (
<span
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
title="该历史检索结果缺少稳定详情标识"
>
<span className="truncate">{item.title}</span>
</span>
);
})}
{(action.resultCount ?? 0) > action.resultItems.length && (
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
</span>
)}
</div>
)}
</div>
) : action.resultTitles.length > 0 ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
结果摘要:{action.resultTitles.join(";")}
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
</p>
) : null}
{action.message && (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
{action.message}
</p>
)}
{action.error && (
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
{action.error}
</p>
)}
{/* Provider warning codes stay in the structured trace for
diagnostics. The old generic warning was misleading both when
useful evidence existed and when the result set was empty. */}
</div>
</div>
</div>
);
}
export function SmartKnowledgeRetrievalTrace({
messages,
progressFrames = [],
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
onOpenResult,
}: {
messages: Message[];
progressFrames?: KnowledgeRetrievalProgressFrame[];
/**
* The whole current user-turn group. Interleaved rendering splits one
* retrieval invocation into one component, but result dedupe must still see
* every invocation in the turn.
*/
dedupeScopeMessages?: Message[];
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
isLoading: boolean;
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
roundOffset?: number;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const rounds = useMemo(
() => {
const localRounds = buildSmartKnowledgeRetrievalRounds(
messages,
progressFrames,
isLoading,
);
if (!dedupeScopeMessages) return localRounds;
const globallyDedupedActions = new Map(
buildSmartKnowledgeRetrievalRounds(
dedupeScopeMessages,
dedupeScopeProgressFrames ?? [],
isLoading,
)
.flatMap((round) => round.actions)
.map((action) => [action.id, action] as const),
);
return localRounds.map((round) => ({
...round,
actions: round.actions.map((action) => {
const globalAction = globallyDedupedActions.get(action.id);
return globalAction ? { ...globalAction, round: action.round } : action;
}),
}));
},
[
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
messages,
progressFrames,
],
);
const actions = rounds.flatMap((round) => round.actions);
// The chat can keep streaming its final answer after retrieval has already
// finished. Only an unfinished retrieval action owns the "检索中" state;
// the broader thread loading flag must not keep the spinner alive or block
// the collapse control during answer generation.
const isActive = actions.some((action) => action.status === "running");
const hasFailure = actions.some((action) => action.status === "failed");
const hasInterrupted = actions.some(
(action) => action.status === "interrupted",
);
const rawHits = actions
.filter((action) => action.surface === "raw" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const wikiHits = actions
.filter((action) => action.surface === "wiki" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const elapsedActions = actions.filter(
(action) => typeof action.elapsedMs === "number",
);
const cumulativeElapsedMs =
elapsedActions.length > 0
? elapsedActions.reduce(
(total, action) => total + (action.elapsedMs ?? 0),
0,
)
: undefined;
// A live trace opens so newly completed result cards remain visible while
// the final answer starts streaming. Replayed history starts collapsed.
const [isExpanded, setIsExpanded] = useState(
isLoading && actions.length > 0,
);
const wasActive = useRef(isActive);
const wasLoading = useRef(isLoading);
useEffect(() => {
if (!wasActive.current && isActive) {
setIsExpanded(true);
}
if (wasLoading.current && !isLoading) {
setIsExpanded(false);
}
wasActive.current = isActive;
wasLoading.current = isLoading;
}, [isActive, isLoading]);
// An in-flight retrieval always stays visible. A just-completed live trace
// remains open and user-collapsible while the answer streams; replayed
// history starts collapsed and can be reopened for inspection.
const isOpen = isActive || isExpanded;
if (actions.length === 0) return null;
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => {
if (!isActive) setIsExpanded(open);
}}
>
<section
aria-label="智能检索过程"
aria-live={isActive ? "polite" : undefined}
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
data-smart-knowledge-retrieval-trace
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{isActive ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SearchCheckIcon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
<div className="flex items-center gap-1.5">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
isActive
? "bg-primary/10 text-primary"
: hasFailure
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: hasInterrupted
? "bg-muted text-muted-foreground"
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
{isActive
? "检索中"
: hasFailure
? "已完成(部分步骤失败)"
: hasInterrupted
? "检索已结束(部分步骤未完成)"
: "检索完成"}
</span>
{!isActive && (
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
>
<span>{isOpen ? "收起" : "展开"}</span>
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
)}
</div>
</div>
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
{typeof cumulativeElapsedMs === "number"
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
: ""}
</p>
)}
{isActive && (
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
</div>
</div>
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
<div className="mt-3 space-y-3">
{actions.map((action) => (
<RetrievalAction
key={action.id}
action={action}
onOpenResult={onOpenResult}
/>
))}
</div>
</CollapsibleContent>
</section>
</Collapsible>
);
}
@@ -158,7 +158,11 @@ function formatBytes(bytes: number): string {
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function useAuthedObjectUrl(
url: string | null,
enabled: boolean,
requestFile: typeof apiFetch = apiFetch,
) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -170,9 +174,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
}
let cancelled = false;
let created: string | null = null;
const controller = new AbortController();
setLoading(true);
setError(null);
apiFetch(url)
requestFile(url, { signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
@@ -191,9 +196,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
});
return () => {
cancelled = true;
controller.abort();
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
}, [url, enabled, requestFile]);
return { objectUrl, loading, error };
}
@@ -213,6 +219,9 @@ export function UploadedFilePreviewDialog({
fileUrl,
threadId,
virtualPath,
requestFile = apiFetch,
onDownload,
downloading = false,
open,
onOpenChange,
}: {
@@ -222,6 +231,11 @@ export function UploadedFilePreviewDialog({
/** Used by the bounded preview endpoint; optional for old/public messages. */
threadId?: string;
virtualPath?: string;
/** External skill links supply an unauthenticated fetch, avoiding token leakage. */
requestFile?: typeof apiFetch;
/** Authenticated original-file download used by retrieved-file cards. */
onDownload?: () => void | Promise<void>;
downloading?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -242,7 +256,7 @@ export function UploadedFilePreviewDialog({
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
// text / office → fetch text (office reads the converted sibling .md).
const textUrls = useMemo(
@@ -279,6 +293,7 @@ export function UploadedFilePreviewDialog({
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
const controller = new AbortController();
setTextLoading(true);
setTextError(null);
setText(null);
@@ -293,7 +308,7 @@ export function UploadedFilePreviewDialog({
)}/artifact-preview?path=${encodeURIComponent(
previewPath,
)}&max_chars=${maxPreviewChars}`;
const response = await apiFetch(endpoint);
const response = await apiFetch(endpoint, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = (await response.json()) as ArtifactPreviewResponse;
const rawContent = String(body.content ?? "");
@@ -320,7 +335,8 @@ export function UploadedFilePreviewDialog({
let lastError: Error | null = null;
for (const textUrl of textUrls) {
try {
const response = await apiFetch(textUrl, {
const response = await requestFile(textUrl, {
signal: controller.signal,
headers: { Range: `bytes=0-${maxBytes - 1}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -356,8 +372,9 @@ export function UploadedFilePreviewDialog({
});
return () => {
cancelled = true;
controller.abort();
};
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]);
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
@@ -384,15 +401,28 @@ export function UploadedFilePreviewDialog({
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
{onDownload ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3" />
{t.common.download}
</Button>
) : (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
)}
</DialogDescription>
</DialogHeader>
@@ -401,7 +431,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<div className="flex items-center justify-center">
<img
@@ -415,7 +445,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<iframe
src={objectUrl}
@@ -427,7 +457,7 @@ export function UploadedFilePreviewDialog({
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<div className="space-y-3">
{previewMeta &&
@@ -465,7 +495,7 @@ export function UploadedFilePreviewDialog({
</div>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
)}
</div>
</DialogContent>
@@ -473,15 +503,34 @@ export function UploadedFilePreviewDialog({
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
fileUrl: string;
label: string;
onDownload?: () => void | Promise<void>;
downloading?: boolean;
}) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<p>暂不支持在线预览,请下载原文件查看。</p>
{onDownload ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</Button>
) : (
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
)}
</div>
);
}
@@ -13,6 +13,8 @@ import {
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { REVEAL_CHAT_HISTORY_EVENT } from "@/core/messages/history-window";
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
import { Tooltip } from "./tooltip";
@@ -142,9 +144,17 @@ export function QuestionNavigator({
}, [visible, questions]);
const scrollToQuestion = useCallback((anchorId: string) => {
document
.getElementById(anchorId)
?.scrollIntoView({ behavior: "smooth", block: "start" });
const scroll = () => {
document
.getElementById(anchorId)
?.scrollIntoView({ behavior: "smooth", block: "start" });
};
if (!document.getElementById(anchorId)) {
window.dispatchEvent(new Event(REVEAL_CHAT_HISTORY_EVENT));
requestAnimationFrame(() => requestAnimationFrame(scroll));
} else {
scroll();
}
setActiveId(anchorId);
}, []);
@@ -5,12 +5,18 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { authStorageRemove } from "@/core/auth/scoped-storage";
import { EXTERNAL_LOGIN_STORAGE_KEYS } from "@/core/auth/external-login";
import { clearLoginCredentials } from "@/core/auth/login-credentials";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { clearYLogToken } from "@/core/log/y-log-token";
import { SettingsSection } from "./settings-section";
import { PasswordSettings } from "./password-settings";
function roleLabel(role?: string) {
if (role === "admin") return "管理员";
@@ -54,17 +60,23 @@ export function AccountSettingsPage() {
});
clearStoredAuth();
clearYLogToken();
for (const key of [
"userInfo", "userId", "tools_token", "access_token",
...Object.values(EXTERNAL_LOGIN_STORAGE_KEYS),
]) authStorageRemove(key);
clearLoginCredentials();
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
void apiFetch(`${getBackendBaseURL()}/api/v1/auth/logout`, { method: "POST" })
.catch(() => undefined)
.finally(() => {
window.location.assign("/");
window.location.assign(`${window.location.pathname}#/login`);
});
};
return (
<div className="space-y-8">
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3">
@@ -106,6 +118,8 @@ export function AccountSettingsPage() {
</div>
</div>
</SettingsSection>
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
</div>
);
}
@@ -0,0 +1,297 @@
"use client";
import { useEffect, useState } from "react";
import { Clock3Icon, DatabaseIcon, GlobeIcon, RefreshCwIcon, SaveIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { getAuditRetentionStatus, setAuditRetentionPaused, type AuditRetentionStatus } from "@/core/admin/audit-retention";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
type CacheCollection = {
id: string;
label: string;
paths: string[];
scope: string;
enabled: boolean;
ttl_seconds: number;
};
type ApiReadCacheStatus = {
enabled: boolean;
redis_configured: boolean;
ttl_seconds: number;
max_value_bytes: number;
browser_status_enabled: boolean;
collections: CacheCollection[];
};
type ApiReadCacheUpdate = {
enabled?: boolean;
ttl_seconds?: number;
browser_status_enabled?: boolean;
collection_overrides?: Record<string, boolean>;
};
async function requestStatus(update?: ApiReadCacheUpdate): Promise<ApiReadCacheStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/api-read-cache`, update ? {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(update),
} : undefined);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(typeof data.detail === "string" ? data.detail : `${update ? "保存" : "读取"}失败(${response.status})`);
}
return data as ApiReadCacheStatus;
}
function retentionLabel(status: string) {
switch (status) {
case "running": return "正在清理";
case "pausing": return "正在停止";
case "paused": return "已关闭";
case "error": return "本轮异常";
default: return "等待执行";
}
}
function dateTime(value?: string | number | null) {
if (!value) return "暂无";
const date = new Date(typeof value === "number" ? value * 1000 : value);
return Number.isNaN(date.getTime()) ? "暂无" : date.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
}
export function ApiReadCacheSettingsPage() {
const [status, setStatus] = useState<ApiReadCacheStatus>();
const [retention, setRetention] = useState<AuditRetentionStatus>();
const [ttlInput, setTtlInput] = useState("300");
const [search, setSearch] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [retentionError, setRetentionError] = useState("");
const refresh = async () => {
setBusy(true);
const [cacheResult, retentionResult] = await Promise.allSettled([
requestStatus(),
getAuditRetentionStatus(),
]);
if (cacheResult.status === "fulfilled") {
setStatus(cacheResult.value);
setTtlInput(String(cacheResult.value.ttl_seconds));
setError("");
} else {
setError(cacheResult.reason instanceof Error ? cacheResult.reason.message : "读取缓存设置失败");
}
if (retentionResult.status === "fulfilled") {
setRetention(retentionResult.value);
setRetentionError("");
} else {
setRetentionError(retentionResult.reason instanceof Error ? retentionResult.reason.message : "读取清理设置失败");
}
setBusy(false);
};
useEffect(() => { void refresh(); }, []);
const save = async (update: ApiReadCacheUpdate, message: string) => {
setBusy(true);
try {
const saved = await requestStatus(update);
setStatus(saved);
if (update.ttl_seconds !== undefined) setTtlInput(String(saved.ttl_seconds));
setError("");
toast.success(message);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存设置失败");
} finally {
setBusy(false);
}
};
const toggleRetention = async (enabled: boolean) => {
setBusy(true);
try {
const saved = await setAuditRetentionPaused(!enabled);
setRetention(saved);
setRetentionError("");
toast.success(enabled ? "定时清理已开启" : saved.pause_requested ? "已关闭定时清理,当前批次结束后停止" : "定时清理已关闭");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存清理设置失败");
} finally {
setBusy(false);
}
};
const parsedTtl = Number(ttlInput);
const validTtl = Number.isInteger(parsedTtl) && parsedTtl >= 1 && parsedTtl <= 3600;
const collections = status?.collections ?? [];
const searchText = search.trim().toLowerCase();
const visibleCollections = collections.filter((item) =>
[item.label, item.id, item.scope, ...item.paths].join(" ").toLowerCase().includes(searchText),
);
const enabledCount = collections.filter((item) => item.enabled).length;
return (
<section className="space-y-5" data-testid="api-read-cache-settings" aria-busy={busy}>
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-xl font-semibold"><DatabaseIcon className="size-5 text-primary" />Redis 与性能管理</h2>
<p className="mt-1 text-sm text-muted-foreground">统一管理接口缓存、浏览器状态查询和历史数据定时清理。</p>
</div>
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void refresh()}>
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />刷新状态
</Button>
</header>
{error && <p role="alert" className="rounded-lg border border-destructive/30 p-3 text-sm text-destructive">{error}</p>}
<div className="overflow-hidden rounded-xl border">
<div className="flex items-center justify-between gap-4 bg-muted/30 p-4">
<div>
<h3 className="font-medium">Redis 读取缓存总开关</h3>
<p className="mt-1 text-sm text-muted-foreground">开关和有效期保存在 MySQL,服务重启后仍生效。</p>
</div>
<Switch
checked={status?.enabled ?? false}
disabled={!status || busy}
onCheckedChange={(enabled) => void save({ enabled }, enabled ? "Redis 读取缓存已开启" : "Redis 读取缓存已关闭")}
aria-label="启用 Redis 读取缓存"
/>
</div>
<div className="space-y-3 border-t p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<label htmlFor="api-cache-ttl" className="text-sm font-medium">默认缓存有效期</label>
<p id="api-cache-ttl-help" className="mt-1 text-xs text-muted-foreground">1–3600 秒,默认 300 秒;保存后刷新缓存。</p>
</div>
<div className="flex items-center gap-2">
<Input
id="api-cache-ttl"
type="number"
min={1}
max={3600}
step={1}
value={ttlInput}
disabled={!status || busy}
onChange={(event) => setTtlInput(event.target.value)}
aria-label="Redis 缓存有效期(秒)"
aria-describedby="api-cache-ttl-help"
aria-invalid={!validTtl}
className="w-24"
/>
<span className="text-sm text-muted-foreground">秒</span>
<Button type="button" size="sm" variant="outline" disabled={!status || busy || !validTtl || parsedTtl === status.ttl_seconds} onClick={() => void save({ ttl_seconds: parsedTtl }, "Redis 缓存有效期已保存")}>
<SaveIcon className="size-4" />保存有效期
</Button>
</div>
</div>
{status && <div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
<Badge variant="secondary">Redis 地址{status.redis_configured ? "已配置" : "未配置"}</Badge>
<span>单条结果上限 {Math.round(status.max_value_bytes / 1024)} KiB</span>
<span>关闭或连接失败时自动使用原查询</span>
</div>}
{status && !status.redis_configured && <p className="text-xs text-amber-700 dark:text-amber-400">当前未配置 Redis 地址。可以保存开关,但配置连接后才会使用缓存。</p>}
</div>
</div>
<div className="rounded-xl border p-4" data-testid="browser-status-control">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="flex items-center gap-2 font-medium"><GlobeIcon className="size-4 text-primary" />允许浏览器状态查询</h3>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">GET /api/browser-context/status</p>
<p className="mt-2 text-sm text-muted-foreground">控制浏览器插件能否访问状态接口;关闭后,服务器会拒绝该接口的查询。</p>
</div>
<Switch
checked={status?.browser_status_enabled ?? false}
disabled={!status || busy}
onCheckedChange={(enabled) => void save({ browser_status_enabled: enabled }, enabled ? "浏览器状态查询已允许" : "浏览器状态查询已关闭")}
aria-label="允许浏览器状态查询"
/>
</div>
</div>
<div className="space-y-3 rounded-xl border p-4" data-testid="audit-retention-control">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="flex items-center gap-2 font-medium"><Clock3Icon className="size-4 text-primary" />开启历史数据定时清理</h3>
<p className="mt-1 text-sm text-muted-foreground">分批清理超过 {retention?.retention_days ?? 90} 天的工具调用、模型调用、已结束任务摘要和排行榜快照。</p>
</div>
<Switch
checked={retention ? !retention.paused : false}
disabled={!retention || busy}
onCheckedChange={(enabled) => void toggleRetention(enabled)}
aria-label="开启历史数据定时清理"
/>
</div>
<p className="text-xs text-muted-foreground">包含 tool_call_metrics、llm_call_metrics;关闭后在当前小批次结束时停止,关闭状态在服务重启后保留。</p>
{retentionError && <p role="alert" className="text-sm text-destructive">{retentionError}</p>}
{retention && <div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-muted/40 px-3 py-2 text-xs">
<Badge variant={retention.display_status === "error" ? "destructive" : "secondary"}>{retentionLabel(retention.display_status)}</Badge>
<span className="text-muted-foreground">间隔 {Math.round((retention.interval_seconds ?? 900) / 60)} 分钟</span>
<span className="text-muted-foreground">下次执行:{retention.paused ? "已关闭" : dateTime(retention.next_run_at)}</span>
<span className="text-muted-foreground">最近完成:{dateTime(retention.last_sweep?.finished_at)}</span>
</div>}
{retention?.pause_requested && <p role="status" className="text-sm text-amber-700 dark:text-amber-400">关闭请求已保存,正在等待当前批次结束。可点击“刷新状态”查看结果。</p>}
{retention?.last_error && <p role="alert" className="text-sm text-destructive">{retention.last_error}</p>}
</div>
<div className="space-y-3" data-testid="cache-collection-list">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="font-medium">已接入 Redis 的接口</h3>
<p className="mt-1 text-xs text-muted-foreground">共 {collections.length} 组,单项开启 {enabledCount} 组;同组接口共用开关。</p>
</div>
<div className="relative w-full sm:w-56">
<SearchIcon className="pointer-events-none absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索名称或接口地址" aria-label="搜索缓存接口" className="pl-9" />
</div>
</div>
<p className="text-xs text-muted-foreground">每组可独立开关,数据修改后缓存失效。总开关关闭时仍可配置单项,重新开启总开关后生效;各接口实际有效期见列表。</p>
{status && !status.enabled && <p className="rounded-lg bg-muted px-3 py-2 text-sm">总开关已关闭,当前所有接口均使用原查询。</p>}
<div className="overflow-x-auto rounded-xl border">
<table className="w-full table-fixed text-left text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="w-[56%] px-4 py-3 font-medium" scope="col">接口 / 缓存范围</th>
<th className="w-[18%] px-2 py-3 font-medium" scope="col">有效期</th>
<th className="w-[26%] px-4 py-3 text-right font-medium" scope="col">使用 Redis</th>
</tr>
</thead>
<tbody className="divide-y">
{visibleCollections.map((item) => {
const effective = item.enabled && status?.enabled && status.redis_configured;
const description = !item.enabled ? "单项已关闭" : !status?.enabled ? "总开关关闭" : !status.redis_configured ? "待配置连接" : "缓存已启用";
return <tr key={item.id} data-testid={`cache-collection-${item.id}`}>
<td className="px-4 py-3 align-top">
<p className="font-medium">{item.label}</p>
<div className="mt-1 space-y-1 text-[11px] text-muted-foreground">
{item.paths.map((path) => <p key={path} className="break-all font-mono">{path}</p>)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{item.scope}</p>
</td>
<td className="px-2 py-3 align-top text-xs">{item.ttl_seconds} 秒</td>
<td className="px-4 py-3 text-right align-top">
<Switch
checked={item.enabled}
disabled={busy}
onCheckedChange={(enabled) => void save({ collection_overrides: { [item.id]: enabled } }, `${item.label}缓存${enabled ? "已开启" : "已关闭"}`)}
aria-label={`${item.label}使用 Redis 缓存`}
/>
<p className={`mt-1 text-[11px] ${effective ? "text-primary" : "text-muted-foreground"}`}>{description}</p>
</td>
</tr>;
})}
{!visibleCollections.length && <tr><td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">{!status ? error ? "接口列表读取失败,请刷新重试" : "正在读取接口列表…" : searchText ? "没有匹配的接口" : "暂无已接入的接口"}</td></tr>}
</tbody>
</table>
</div>
</div>
</section>
);
}
@@ -0,0 +1,273 @@
"use client";
import { ImageUp, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useTheme } from "@/contexts/ThemeContext";
import {
getHeaderBrandAsset,
getHeaderBrandFallback,
} from "@/core/branding/brand-assets";
import { resolveAppIdentityLogoUrl } from "@/core/system-settings/api";
import {
useAppIdentitySettings,
useDeleteAppIdentitySettings,
useUpdateAppIdentitySettings,
} from "@/core/system-settings/hooks";
import { SettingsSection } from "./settings-section";
const MAX_LOGO_BYTES = 10 * 1024 * 1024;
const ACCEPTED_LOGO_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
export function AppIdentitySettingsPage() {
const { theme } = useTheme();
const staticLogo = getHeaderBrandAsset(theme);
const staticFallback = getHeaderBrandFallback(theme);
const [staticLogoFailed, setStaticLogoFailed] = useState(false);
const { data: identity, isPending } = useAppIdentitySettings();
const updateIdentity = useUpdateAppIdentitySettings();
const deleteIdentity = useDeleteAppIdentitySettings();
const fileInputRef = useRef<HTMLInputElement>(null);
const [systemName, setSystemName] = useState("");
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
const [removeLogo, setRemoveLogo] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
useEffect(() => {
setSystemName(identity?.system_name ?? "");
setPendingLogo(null);
setRemoveLogo(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [identity?.system_name, identity?.logo_version, identity?.has_logo]);
const previewUrl = useMemo(
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
[pendingLogo],
);
useEffect(
() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
[previewUrl],
);
const savedLogoUrl = removeLogo
? undefined
: resolveAppIdentityLogoUrl(identity?.logo_url);
const displayedLogoUrl = previewUrl ?? savedLogoUrl;
const normalizedName = systemName.trim().replace(/\s+/g, " ");
const hasChanges =
normalizedName !== (identity?.system_name ?? "") ||
Boolean(pendingLogo) ||
removeLogo;
const customized = Boolean(identity?.system_name || identity?.has_logo);
const busy = updateIdentity.isPending || deleteIdentity.isPending;
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!ACCEPTED_LOGO_TYPES.has(file.type)) {
toast.error("Logo 仅支持 PNG、JPG 或 WebP 图片");
event.target.value = "";
return;
}
if (file.size > MAX_LOGO_BYTES) {
toast.error("Logo 文件不能超过 10MB");
event.target.value = "";
return;
}
setPendingLogo(file);
setRemoveLogo(false);
};
const handleUseStaticLogo = () => {
setPendingLogo(null);
setRemoveLogo(true);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSave = async () => {
try {
await updateIdentity.mutateAsync({
systemName: normalizedName,
logo: pendingLogo,
removeLogo,
});
toast.success("系统名称已保存", {
description: "导航、侧栏和欢迎区会使用这里的名称和 Logo。",
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "系统名称保存失败");
}
};
const handleReset = async () => {
try {
await deleteIdentity.mutateAsync();
setResetOpen(false);
toast.success("已恢复默认系统名称和 Logo");
} catch (error) {
toast.error(error instanceof Error ? error.message : "清除系统名称失败");
}
};
return (
<>
<SettingsSection
title="系统名称"
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo。名称留空时继续使用现有名称;没有上传 Logo 时继续使用内置静态图片。这里不会改「生成网页标识」。"
>
<div className="space-y-6">
<div className="space-y-2">
<label htmlFor="app-identity-name" className="text-sm font-medium">
系统名称
</label>
<Input
id="app-identity-name"
value={systemName}
onChange={(event) => setSystemName(event.target.value)}
placeholder="留空则继续使用 CM业务中心 / CM 助手"
maxLength={80}
disabled={isPending || busy}
/>
<div className="flex justify-between gap-4 text-xs text-muted-foreground">
<span>最多 80 个字符。</span>
<span className="tabular-nums">{systemName.length}/80</span>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">系统 Logo</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
上传后,导航、侧栏和欢迎区都使用这一张图片。未上传时按当前主题使用内置 Logo。
</p>
</div>
<div className="grid gap-4 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[160px_minmax(0,1fr)]">
<div className="flex h-28 items-center justify-center overflow-hidden rounded-lg border border-dashed bg-background p-3">
{displayedLogoUrl ? (
<img
src={displayedLogoUrl}
alt="系统 Logo 预览"
className="max-h-full max-w-full object-contain"
/>
) : (
<img
src={staticLogoFailed ? staticFallback : staticLogo}
alt="当前静态 Logo"
className="max-h-full max-w-full object-contain"
onError={() => setStaticLogoFailed(true)}
/>
)}
</div>
<div className="flex min-w-0 flex-col justify-center gap-3">
<input
ref={fileInputRef}
id="app-identity-logo"
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={handleLogoChange}
disabled={isPending || busy}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => fileInputRef.current?.click()}
disabled={isPending || busy}
>
<ImageUp aria-hidden />
{displayedLogoUrl ? "更换 Logo" : "上传 Logo"}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={handleUseStaticLogo}
disabled={
isPending ||
busy ||
(!identity?.has_logo && !pendingLogo) ||
(removeLogo && !pendingLogo)
}
>
使用静态 Logo
</Button>
</div>
{pendingLogo && (
<p className="truncate text-xs text-muted-foreground" title={pendingLogo.name}>
待上传:{pendingLogo.name}
</p>
)}
{removeLogo && (
<p className="text-xs text-muted-foreground">
保存后恢复为内置静态 Logo。
</p>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => setResetOpen(true)}
disabled={!customized || busy}
>
<RotateCcw aria-hidden />
清除配置
</Button>
<Button
type="button"
className="h-11 min-w-28"
onClick={handleSave}
disabled={!hasChanges || isPending || busy}
>
{updateIdentity.isPending && <Loader2 className="animate-spin" aria-hidden />}
{updateIdentity.isPending ? "保存中..." : "保存系统名称"}
</Button>
</div>
</div>
</SettingsSection>
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>清除系统名称和 Logo?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。
</p>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetOpen(false)}>
取消
</Button>
<Button type="button" onClick={handleReset} disabled={busy}>
清除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { AUTH_STATE_CHANGED_EVENT, getStoredAuth } from "@/core/auth";
import {
getAuthenticationSettings,
saveAuthenticationSettings,
type AuthenticationSettings,
} from "@/core/auth/account-settings-api";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { SettingsSection } from "./settings-section";
export function AuthenticationSettingsPage({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const queryClient = useQueryClient();
const queryKey = ["authentication-settings", live.userId];
const query = useQuery({ queryKey, queryFn: getAuthenticationSettings, enabled: isAdmin });
const [draft, setDraft] = useState<AuthenticationSettings | null>(null);
const mutation = useMutation({ mutationFn: saveAuthenticationSettings });
const settings = draft ?? query.data;
if (!isAdmin) return null;
const update = (key: keyof AuthenticationSettings, value: boolean) => {
if (!settings) return;
const next = { ...settings, [key]: value };
if (key === "password_login_enabled" && !value) next.registration_enabled = false;
setDraft(next);
};
const save = async () => {
if (!settings || mutation.isPending) return;
try {
const saved = await mutation.mutateAsync(settings);
queryClient.setQueryData(queryKey, saved);
setDraft(null);
void queryClient.invalidateQueries({ queryKey: ["auth-options"] });
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
toast.success("登录与注册设置已保存,即时生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const invalid = settings && !settings.password_login_enabled && !settings.sso_enabled;
return (
<SettingsSection title="登录与注册" description="管理登录页面、自主注册与账号在线限制,保存后即时生效。">
{query.isPending ? (
<p className="text-muted-foreground text-sm">正在加载设置…</p>
) : query.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{query.error.message}</p>
<Button variant="outline" onClick={() => void query.refetch()}>重新加载</Button>
</div>
) : settings && (
<div className="space-y-5">
<div className="divide-y rounded-xl border px-5">
<SettingSwitch id="password-login-enabled" title="账号密码登录" description="默认关闭。开启后须登录才能进入系统。未设置个人密码的账号使用现有系统口令。" checked={settings.password_login_enabled} disabled={mutation.isPending} onChange={(value) => update("password_login_enabled", value)} />
<SettingSwitch id="registration-enabled" title="用户自主注册" description="允许用户在注册页创建自己的普通账号。需先开启账号密码登录。" checked={settings.registration_enabled} disabled={mutation.isPending || !settings.password_login_enabled} onChange={(value) => update("registration_enabled", value)} />
<SettingSwitch id="single-session-enabled" title="单点登录" description="默认关闭。开启后同一账号只允许一处在线,新登录会使其他登录会话自动退出。" checked={settings.single_session_enabled ?? false} disabled={mutation.isPending} onChange={(value) => update("single_session_enabled", value)} />
<SettingSwitch id="sso-enabled" title="外部系统免登" description="允许外部系统通过票据、令牌或口令链接登录,与单点登录的在线限制独立。" checked={settings.sso_enabled} disabled={mutation.isPending} onChange={(value) => update("sso_enabled", value)} />
</div>
<p className="text-muted-foreground text-sm">密码与登录口令共用。至少保留账号密码登录或外部系统免登中的一种;关闭自主注册不影响已有账号。</p>
<Button variant="outline" onClick={() => { onClose?.(); navigate("/page/strategy/admin/login-whitelist"); }}>管理用户密码与登录口令</Button>
{invalid && <p role="alert" className="text-destructive text-sm">请至少开启账号密码登录或外部系统免登。</p>}
<div className="flex justify-end">
<Button disabled={!draft || Boolean(invalid) || mutation.isPending} onClick={() => void save()}>{mutation.isPending ? "保存中…" : "保存设置"}</Button>
</div>
</div>
)}
</SettingsSection>
);
}
function SettingSwitch({ id, title, description, checked, disabled, onChange }: {
id: string;
title: string;
description: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-5 py-5">
<div className="space-y-1">
<label htmlFor={id} className="cursor-pointer text-sm font-medium">{title}</label>
<p id={`${id}-description`} className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch id={id} aria-describedby={`${id}-description`} checked={checked} disabled={disabled} onCheckedChange={onChange} />
</div>
);
}
@@ -0,0 +1,253 @@
"use client";
import { ArchiveIcon, DatabaseIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getStoredAuth } from "@/core/auth";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import {
ACTIVE_ARCHIVE_JOB_STATUSES as ACTIVE,
archiveMonthDisplayStatus,
canPurgeArchivedMonth,
isArchiveMonthActive,
} from "@/core/chat-archive-state";
type ArchiveJob = {
job_id: string;
action: "archive" | "purge" | "rollback";
status: string;
total_threads: number;
processed_threads: number;
failed_threads: number;
current_thread_id?: string | null;
snapshot_at?: string;
processed_through?: string | null;
message?: string;
error?: string | null;
};
type ArchiveMonth = {
month: string;
thread_count: number;
status: string;
copied_threads: number;
deleted_threads: number;
sqlite_bytes: number;
latest_job?: ArchiveJob;
};
type Inventory = {
archive_dir: string;
disk_total_bytes: number;
disk_free_bytes: number;
month_basis: string;
warning: string;
months: ArchiveMonth[];
};
function formatBytes(bytes: number) {
if (!bytes) return "尚无归档文件";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(unit >= 3 ? 2 : 1)} ${units[unit]}`;
}
function currentLocalMonth() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function statusLabel(status: string) {
switch (status) {
case "ready": return "归档已校验";
case "deleted": return "PG 已清理";
case "copying": return "正在归档";
case "deleting": return "正在清理 PG";
case "restoring": return "正在回滚";
case "cancelling": return "正在停止";
case "partial": return "部分完成";
case "interrupted": return "已中断,可重新归档";
default: return "尚未归档";
}
}
function statusTone(status: string): "default" | "secondary" | "outline" | "destructive" {
if (status === "deleted" || status === "ready") return "default";
if (status === "partial" || status === "interrupted") return "destructive";
if (ACTIVE.has(status) || ["copying", "deleting", "restoring"].includes(status)) return "secondary";
return "outline";
}
export function ChatArchiveSettingsPage() {
const [inventory, setInventory] = useState<Inventory | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyMonth, setBusyMonth] = useState<string | null>(null);
const load = useCallback(async (signal?: AbortSignal) => {
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/chat-archive/months`, { signal });
if (!response.ok) throw new Error((await response.text()) || `加载失败 (${response.status})`);
setInventory(await response.json() as Inventory);
setError(null);
} catch (cause) {
if (signal?.aborted) return;
setError(cause instanceof Error ? cause.message : "读取归档月份失败");
} finally {
if (!signal?.aborted) setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
useEffect(() => {
const activeJobs = inventory?.months
.map((month) => month.latest_job)
.filter((job): job is ArchiveJob => Boolean(job && ACTIVE.has(job.status)));
if (!activeJobs?.length) return;
const timer = window.setInterval(() => {
void Promise.all(activeJobs.map(async (job) => {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/chat-archive/jobs/${job.job_id}`);
if (!response.ok) return null;
return await response.json() as ArchiveJob;
})).then((jobs) => {
const updates = jobs.filter((job): job is ArchiveJob => Boolean(job));
if (!updates.length) return;
setInventory((current) => current ? {
...current,
months: current.months.map((month) => {
const job = updates.find((item) => item.job_id === month.latest_job?.job_id);
return job ? { ...month, latest_job: job } : month;
}),
} : current);
if (updates.some((job) => !ACTIVE.has(job.status))) void load();
}).catch(() => undefined);
}, 2500);
return () => window.clearInterval(timer);
}, [inventory, load]);
const runAction = async (month: ArchiveMonth, action: ArchiveJob["action"]) => {
const confirmation = action === "purge"
? "确认清理该月已归档的 PostgreSQL 数据?系统会逐会话重新校验 SQLite 备份,只清理校验通过的会话;失败、未归档和正在运行的会话不会清理。"
: "确认回滚到 PostgreSQL?月度 SQLite 文件中的归档记录会保留。";
if (action !== "archive" && !window.confirm(confirmation)) return;
setBusyMonth(month.month);
setError(null);
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/chat-archive/months/${month.month}/${action === "purge" ? "delete-postgres" : action}`, { method: "POST" });
if (!response.ok) throw new Error((await response.text()) || `操作失败 (${response.status})`);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "操作失败");
} finally {
setBusyMonth(null);
}
};
const isAdmin = getStoredAuth()?.system_role === "admin";
if (!isAdmin) return <Alert><AlertTitle>仅管理员可用</AlertTitle><AlertDescription>聊天归档管理需要管理员权限。</AlertDescription></Alert>;
return (
<section className="space-y-5" data-testid="chat-archive-settings">
<div>
<h2 className="text-lg font-semibold">聊天记录月度归档</h2>
<p className="text-muted-foreground mt-1 text-sm">整条会话按创建月份归档(跨月消息一起保留):先复制并校验到按月 SQLite 文件,再单独清理 PostgreSQL;历史会话仍可读取,必要时可回滚。单纯部署或打开此页不会扫描检查点表或改变当前聊天存储;只有管理员启动归档、清理或回滚任务后才会处理数据。</p>
</div>
<Alert>
<DatabaseIcon className="size-4" />
<AlertTitle>归档目录</AlertTitle>
<AlertDescription className="space-y-1 break-all">
<div>{inventory?.archive_dir ?? "正在读取…"}</div>
<div>目标磁盘可用 {inventory ? formatBytes(inventory.disk_free_bytes) : "…"} / {inventory ? formatBytes(inventory.disk_total_bytes) : "…"}。归档过程中保留至少 2 GiB 空间;若不足,任务会暂停并保留 PG 数据。</div>
</AlertDescription>
</Alert>
<Alert>
<AlertTitle>低负载处理方式</AlertTitle>
<AlertDescription>{inventory?.warning ?? "月份按会话创建时间展示;归档任务在后台逐会话处理,页面关闭不影响执行;中断后可重新执行。"} 单个会话失败会记录错误并继续处理其他会话;部分归档完成后也可清理已校验的 PG 数据,未归档或校验失败的会话会保留。磁盘不足等整体故障仍会停止任务。删除 PostgreSQL 前会重新校验目标 SQLite 文件中的检查点;清理按会话逐条执行,每条间隔 0.5 秒,运行中的问答会跳过并计入进度,结束后可重新清理。清理后空间可供 PostgreSQL 后续复用;表文件大小不会保证立即缩小,系统不会自动运行可能带来较大 I/O 的 VACUUM FULL。</AlertDescription>
</Alert>
{error && <Alert variant="destructive"><AlertTitle>操作未完成</AlertTitle><AlertDescription className="break-all">{error}</AlertDescription></Alert>}
{loading ? <p className="text-muted-foreground text-sm">正在读取月份…</p> : inventory?.months.length ? (
<div className="space-y-3">
{inventory.months.map((month) => {
const job = month.latest_job;
const active = isArchiveMonthActive(month);
const displayStatus = archiveMonthDisplayStatus(month);
const canPurge = canPurgeArchivedMonth(month);
const canRollback = month.deleted_threads > 0;
return (
<Card key={month.month}>
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{month.month}</CardTitle>
<CardDescription className="mt-1">{month.thread_count.toLocaleString()} 个会话 · SQLite:{formatBytes(month.sqlite_bytes)}</CardDescription>
</div>
<Badge variant={statusTone(displayStatus)}>{statusLabel(displayStatus)}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-xs">已复制 {month.copied_threads.toLocaleString()} 个 · PG 已清理 {month.deleted_threads.toLocaleString()} 个</p>
{job && <div className="bg-muted/50 rounded-md p-3 text-sm">
<div>{job.message || "任务状态已更新"}</div>
<div className="text-muted-foreground mt-1 text-xs">
已处理 {job.processed_threads.toLocaleString()} / {job.total_threads.toLocaleString()} 个
{job.failed_threads > 0 && ` · 失败或跳过 ${job.failed_threads.toLocaleString()} 个`}
</div>
{job.failed_threads > 0 && <div className="text-muted-foreground mt-1 text-xs">{ACTIVE.has(job.status) ? "将继续处理其他会话,未完成项可稍后重试。" : "未完成项可重新执行;已有备份不会因单个会话失败而丢弃。"}</div>}
{job.action === "archive" && job.snapshot_at && <div className="text-muted-foreground mt-1 text-xs">本次数据快照截至:{new Date(job.snapshot_at).toLocaleString()}</div>}
{job.action === "archive" && job.processed_through && <div className="text-muted-foreground mt-1 text-xs">已处理到会话创建时间:{new Date(job.processed_through).toLocaleString()}</div>}
{ACTIVE.has(job.status) && <div className="mt-2 space-y-1">
<div
role="progressbar"
aria-label={`${month.month} ${job.action === "archive" ? "聊天归档" : job.action === "purge" ? "PostgreSQL 清理" : "聊天回滚"}进度`}
aria-valuemin={0}
aria-valuemax={Math.max(1, job.total_threads)}
aria-valuenow={Math.min(job.processed_threads, job.total_threads)}
className="bg-background h-2 overflow-hidden rounded-full"
>
<div className="bg-primary h-full transition-[width]" style={{ width: `${job.total_threads ? Math.min(100, (job.processed_threads / job.total_threads) * 100) : 100}%` }} />
</div>
<div className="text-muted-foreground text-xs">{job.total_threads ? Math.floor((job.processed_threads / job.total_threads) * 100) : 100}%</div>
</div>}
{job.current_thread_id && <div className="text-muted-foreground mt-1 break-all text-xs">{ACTIVE.has(job.status) ? "当前会话" : "最后处理会话"}:{job.current_thread_id}</div>}
{job.error && <div className="text-destructive mt-1 break-all text-xs">最近错误:{job.error}</div>}
</div>}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={active || busyMonth === month.month || month.month > currentLocalMonth()} onClick={() => void runAction(month, "archive")}>
<ArchiveIcon className="mr-2 size-4" />{displayStatus === "interrupted" || displayStatus === "partial" ? "重新归档并校验" : month.month === currentLocalMonth() ? "归档本月快照" : month.copied_threads ? "重新校验归档" : "归档到 SQLite"}
</Button>
<Button size="sm" variant="destructive" disabled={active || !canPurge || busyMonth === month.month} onClick={() => void runAction(month, "purge")}>
<Trash2Icon className="mr-2 size-4" />{month.status === "deleted" ? "再次清理 PG" : "删除已归档 PG 数据"}
</Button>
<Button size="sm" variant="outline" disabled={active || !canRollback || busyMonth === month.month} onClick={() => void runAction(month, "rollback")}>
<RotateCcwIcon className="mr-2 size-4" />回滚到 PostgreSQL
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<Alert><AlertTitle>暂无可归档月份</AlertTitle><AlertDescription>业务库中还没有可识别的会话元数据。</AlertDescription></Alert>
)}
</section>
);
}
@@ -0,0 +1,243 @@
"use client";
import { ListOrderedIcon, Loader2Icon, ShrinkIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useConversationContextSettings,
useUpdateConversationContextSettings,
} from "@/core/system-settings/hooks";
export function ContextWindowSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useConversationContextSettings();
const update = useUpdateConversationContextSettings();
const enabled = data?.recent_turn_window_enabled ?? false;
const recentTurns = data?.recent_turns ?? 3;
const currentRoundLimit = data?.max_conversation_rounds ?? 20;
const [roundLimit, setRoundLimit] = useState(String(currentRoundLimit));
const [roundLimitTouched, setRoundLimitTouched] = useState(false);
const parsedRoundLimit = Number(roundLimit);
const roundLimitInvalid =
!Number.isInteger(parsedRoundLimit) ||
parsedRoundLimit < 1 ||
parsedRoundLimit > 50;
const useQuestionAsTitle = data?.use_question_as_title ?? false;
const [stepDraft, setStepDraft] = useState<string | null>(null);
const stepValue = stepDraft ?? String(data?.max_run_steps ?? 1000);
const steps = Number(stepValue);
const validSteps = stepValue.trim() !== "" && Number.isInteger(steps) && steps >= 20 && steps <= 10000;
useEffect(() => {
setRoundLimit(String(currentRoundLimit));
setRoundLimitTouched(false);
}, [currentRoundLimit]);
const saveSteps = async () => {
if (!validSteps) return;
try {
await update.mutateAsync({ max_run_steps: steps });
setStepDraft(null);
toast.success("单次问答步骤上限已保存,从下一次问答生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ recent_turn_window_enabled: checked });
toast.success(
checked
? `近 ${recentTurns} 轮上下文模式已开启`
: `近 ${recentTurns} 轮上下文模式已关闭`,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const saveRoundLimit = async () => {
setRoundLimitTouched(true);
if (roundLimitInvalid) {
toast.error("对话轮数限制请填写 1 到 50 的整数");
return;
}
try {
await update.mutateAsync({ max_conversation_rounds: parsedRoundLimit });
toast.success(`单个对话最多可进行 ${parsedRoundLimit} 轮`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleQuestionTitleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ use_question_as_title: checked });
toast.success(
checked
? "已开启:对话标题使用首个问题的前 20 个字"
: "已关闭:恢复回答后的标题生成",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<ShrinkIcon className="size-5" />
上下文管理
</h3>
<p className="text-muted-foreground text-sm">
管理对话历史范围、单个对话轮数、标题生成方式和单次问答步骤上限。设置对所有用户生效,仅管理员可修改。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">仅保留最近 {recentTurns} 轮对话</div>
<p className="text-muted-foreground text-sm">
开启后,主问答引擎的模型请求只携带最近 {recentTurns}
个真实用户回合(含当前轮)及其回复和工具结果,不再调用摘要模型。完整聊天记录仍会保存并显示;若这三轮本身仍超过模型上限,最终安全限制会继续裁剪超长内容。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label={`仅保留最近 ${recentTurns} 轮对话`}
/>
)}
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<div className="min-w-0 space-y-1">
<label
htmlFor="max-conversation-rounds"
className="flex items-center gap-2 font-medium"
>
<ListOrderedIcon className="size-4" />
单个对话轮数限制
</label>
<p
id="max-conversation-rounds-help"
className="text-muted-foreground text-sm"
>
一轮按一条真实用户提问计算。默认 20,可设置为 1 到 50;达到上限后用户需要新建对话才能继续。
</p>
<div className="mt-2 flex items-center gap-2">
<Input
id="max-conversation-rounds"
type="number"
min={1}
max={50}
inputMode="numeric"
value={roundLimit}
disabled={isLoading || isError || update.isPending}
onChange={(event) => setRoundLimit(event.target.value)}
onBlur={() => setRoundLimitTouched(true)}
aria-invalid={roundLimitTouched && roundLimitInvalid}
aria-describedby="max-conversation-rounds-help max-conversation-rounds-error"
className="w-28"
/>
<span className="text-muted-foreground text-sm">轮</span>
</div>
<p
id="max-conversation-rounds-error"
className={
roundLimitTouched && roundLimitInvalid
? "text-destructive min-h-5 text-xs"
: "text-muted-foreground min-h-5 text-xs"
}
role={roundLimitTouched && roundLimitInvalid ? "alert" : undefined}
>
{roundLimitTouched && roundLimitInvalid
? "请输入 1 到 50 的整数。"
: "保存后,新限制会应用到当前及后续普通对话。"}
</p>
</div>
<Button
type="button"
size="sm"
disabled={
isLoading ||
isError ||
update.isPending ||
(!roundLimitInvalid && parsedRoundLimit === currentRoundLimit)
}
onClick={() => void saveRoundLimit()}
>
{update.isPending ? "保存中…" : "保存轮数"}
</Button>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">标题直接使用首个问题</div>
<p className="text-muted-foreground text-sm">
开启后,取首个问题的前 20 个字作为最终标题,不足 20 个字则完整保留;回答结束后不再额外调用模型生成标题,也不轮询标题状态,避免影响下一轮提问。关闭后恢复原来的回答后标题生成方式。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={useQuestionAsTitle}
disabled={isError || update.isPending}
onCheckedChange={handleQuestionTitleChange}
aria-label="标题直接使用首个问题"
/>
)}
</div>
<div className="space-y-3 rounded-lg border p-4">
<label htmlFor="max-run-steps" className="font-medium">单次问答步骤上限</label>
<p className="text-muted-foreground text-sm">
控制普通问答和智能体问答每次运行的最大执行步数(20–10000,默认 1000)。步骤包括模型、工具及内部处理,不等于界面显示的步骤条数。提高上限可支持更复杂的任务,也会增加耗时和用量。
</p>
<div className="flex items-center gap-3">
<Input id="max-run-steps" type="number" min={20} max={10000} step={1}
className="w-40" value={stepValue}
disabled={isLoading || isError || update.isPending}
onChange={(event) => setStepDraft(event.target.value)} />
<Button disabled={isLoading || isError || update.isPending || !validSteps}
onClick={() => void saveSteps()}>保存上限</Button>
</div>
{!validSteps && <p className="text-destructive text-sm">请输入 20 到 10000 之间的整数。</p>}
<p className="text-muted-foreground text-sm">
达到上限时保留已有对话内容并提示暂停;用户可发送“继续”,在当前对话中接着处理。
</p>
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前上下文模式,本页暂不能修改;服务端会继续保持原设置。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
开启后优先于聊天框中的“上下文压缩”选项,并从下一次新问答或运行开始生效,无需重启服务。
</p>
</section>
);
}
@@ -0,0 +1,112 @@
"use client";
import { Loader2Icon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useOrdinaryKnowledgeRetrievalSettings,
useUpdateOrdinaryKnowledgeRetrievalSettings,
} from "@/core/system-settings/hooks";
export function KnowledgeRetrievalSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useOrdinaryKnowledgeRetrievalSettings();
const update = useUpdateOrdinaryKnowledgeRetrievalSettings();
const enabled = data?.smart_reasoning_enabled ?? false;
const advancedManagementEnabled =
data?.advanced_management_enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ smart_reasoning_enabled: checked });
toast.success(
checked ? "普通知识库智能检索已开启" : "普通知识库智能检索已关闭",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleAdvancedManagementChange = async (checked: boolean) => {
try {
await update.mutateAsync({ advanced_management_enabled: checked });
toast.success(
checked ? "知识库高级管理入口已开放" : "知识库高级管理入口已隐藏",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<SearchIcon className="size-5" />
知识检索
</h3>
<p className="text-muted-foreground text-sm">
配置普通知识库可用的检索模式。新对话始终默认使用经典检索。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">普通知识库智能推理检索</div>
<p className="text-muted-foreground text-sm">
开启后,用户可在问答输入框中从经典检索切换到智能检索;经典检索仍位于首项并作为默认模式。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="普通知识库智能推理检索"
/>
)}
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">知识库高级管理入口</div>
<p className="text-muted-foreground text-sm">
默认隐藏检索测试、增量同步和知识库数据包导入导出。开启后按原有账号权限显示;
基础知识库管理和查看 Wiki 索引始终可用。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={advancedManagementEnabled}
disabled={isError || update.isPending}
onCheckedChange={handleAdvancedManagementChange}
aria-label="知识库高级管理入口"
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前知识检索设置,客户端按关闭处理,本页暂不能修改。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
</section>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useModels } from "@/core/models/hooks";
type ModelPrompt = { enabled: boolean; prompt: string };
const queryKey = ["system-settings", "model-prompt-suffixes"];
const endpoint = () => `${getBackendBaseURL()}/api/system-settings/model-prompt-suffixes`;
async function readResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(typeof body?.detail === "string" ? body.detail : "模型提示词设置请求失败");
}
return response.json() as Promise<T>;
}
export function ModelPromptSettingsPage() {
const client = useQueryClient();
const { models, isLoading: modelsLoading, error: modelsError } = useModels();
const query = useQuery({
queryKey,
queryFn: async () => readResponse<Record<string, ModelPrompt>>(await apiFetch(endpoint())),
});
const [selected, setSelected] = useState("");
const [drafts, setDrafts] = useState<Record<string, ModelPrompt>>({});
const names = [...new Set([...models.map((model) => model.name), ...Object.keys(query.data ?? {})])];
const modelName = selected || names[0] || "";
const saved = query.data?.[modelName] ?? { enabled: false, prompt: "" };
const draft = drafts[modelName] ?? saved;
const mutation = useMutation({
mutationFn: async ({ name, value }: { name: string; value: ModelPrompt }) =>
readResponse<ModelPrompt>(await apiFetch(`${endpoint()}/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
})),
onSuccess: (value, { name }) => {
client.setQueryData<Record<string, ModelPrompt>>(queryKey, (old) => ({ ...old, [name]: value }));
setDrafts((old) => {
const next = { ...old };
delete next[name];
return next;
});
toast.success("已保存,所有用户下一次调用该模型时生效");
},
onError: (error) => toast.error(error.message),
});
const edit = (patch: Partial<ModelPrompt>) => setDrafts((old) => ({ ...old, [modelName]: { ...draft, ...patch } }));
if (query.isLoading || modelsLoading) return <p>正在加载模型提示词设置…</p>;
if (query.isError || modelsError) return (
<div className="space-y-3">
<p role="alert">加载失败,请重试。</p>
<Button onClick={() => { void query.refetch(); void client.invalidateQueries({ queryKey: ["models"] }); }}>重试</Button>
</div>
);
return (
<section className="space-y-5">
<div className="space-y-2">
<h3 className="text-lg font-semibold">模型追加提示词</h3>
<p className="text-muted-foreground text-sm">
为指定模型设置固定提示词,自动追加到系统提示词末尾。仅管理员可修改,对所有用户生效,保存后无需重启。
</p>
<p className="text-muted-foreground text-sm">
适用于平台统一模型入口的问答、智能体及其他模型调用。追加提示词用于引导回答方式,不能保证模型一定回答。
</p>
</div>
{names.length === 0 ? <p>暂无可配置的模型。</p> : <>
<label className="block space-y-2">
<span className="text-sm font-medium">选择模型</span>
<select className="bg-background w-full rounded-md border px-3 py-2" value={modelName}
disabled={mutation.isPending} onChange={(event) => setSelected(event.target.value)}>
{names.map((name) => {
const model = models.find((item) => item.name === name);
return <option key={name} value={name}>{model ? `${model.display_name || name}(${name})` : `${name}(当前未配置)`}</option>;
})}
</select>
</label>
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
<label htmlFor="model-prompt-enabled" className="text-sm font-medium">启用此模型的追加提示词</label>
<Switch id="model-prompt-enabled" checked={draft.enabled} disabled={mutation.isPending}
onCheckedChange={(enabled) => edit({ enabled })} />
</div>
<label className="block space-y-2">
<span className="text-sm font-medium">追加提示词</span>
<Textarea rows={12} maxLength={20000} value={draft.prompt} disabled={mutation.isPending}
placeholder="填写希望该模型遵循的回答要求"
onChange={(event) => edit({ prompt: event.target.value })} />
</label>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground text-xs">{draft.prompt.length} / 20000 字符;关闭开关可停用并保留内容。</span>
<Button disabled={mutation.isPending || (draft.enabled && !draft.prompt.trim())}
onClick={() => mutation.mutate({ name: modelName, value: draft })}>
{mutation.isPending ? "保存中…" : "保存此模型设置"}
</Button>
</div>
</>}
</section>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getStoredAuth, setStoredAuth } from "@/core/auth";
import { changeAccountPassword, getAccountPasswordStatus } from "@/core/auth/account-settings-api";
import { SettingsSection } from "./settings-section";
export function PasswordSettings({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const status = useQuery({
queryKey: ["account-password", userId],
queryFn: getAccountPasswordStatus,
});
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const hasPassword = status.data?.has_password ?? true;
const requiresCurrentPassword = status.data?.requires_current_password ?? true;
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (pending || !status.data) return;
setError("");
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
if (newPassword.length < 8) {
setError("新密码至少需要 8 位,请避免常见弱密码");
return;
}
const auth = getStoredAuth();
if (!auth || auth.user_id !== userId) {
setError("登录账号已变化,请重新打开个人信息");
return;
}
setPending(true);
try {
const session = await changeAccountPassword(requiresCurrentPassword ? currentPassword : "", newPassword);
// Another tab may have changed the active account while the request was pending.
if (getStoredAuth()?.access_token !== auth.access_token) return;
if (session.access_token) {
setStoredAuth({
...auth,
access_token: session.access_token,
token_type: session.token_type ?? auth.token_type,
expires_in: session.expires_in ?? auth.expires_in,
needs_setup: false,
});
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
queryClient.setQueryData(["account-password", userId], { ...status.data, has_password: true, requires_current_password: true, uses_default_password: false });
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
toast.success("密码与登录口令已同步更新,其他登录会话已失效");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "密码修改失败");
} finally {
setPending(false);
}
};
return (
<SettingsSection title={hasPassword ? "修改密码" : "设置密码"} description="密码与登录口令共用,修改后原密码和原口令链接失效。">
{status.isPending ? <p className="text-muted-foreground text-sm">正在读取密码状态…</p> : status.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{status.error.message}</p>
<Button variant="outline" onClick={() => void status.refetch()}>重新加载</Button>
</div>
) : (
<form onSubmit={(event) => void submit(event)} className="space-y-4 rounded-xl border p-5">
{!hasPassword && <p className="text-muted-foreground text-sm">此账号尚未设置个人密码,当前使用系统口令。请先输入现有口令,再设置个人密码。</p>}
{requiresCurrentPassword && (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-current-password">当前密码 / 口令</label>
<Input id="account-current-password" type="password" autoComplete="current-password" required value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={pending} />
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-new-password">新密码</label>
<Input id="account-new-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} placeholder="至少 8 位,避免常见弱密码" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={pending} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-confirm-password">确认新密码</label>
<Input id="account-confirm-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={pending} />
</div>
{error && <p role="alert" className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end"><Button type="submit" disabled={pending}>{pending ? "保存中…" : "保存密码"}</Button></div>
</form>
)}
</SettingsSection>
);
}
@@ -0,0 +1,74 @@
"use client";
import { HistoryIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useRetryKeepPartialSettings,
useUpdateRetryKeepPartialSettings,
} from "@/core/system-settings/hooks";
export function RetryKeepPartialSettingsPage() {
const { data, isLoading, isError } = useRetryKeepPartialSettings();
const update = useUpdateRetryKeepPartialSettings();
const enabled = data?.enabled ?? true;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(
checked ? "保留中间正文已开启" : "保留中间正文已关闭",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<HistoryIcon className="size-5" />
保留中间正文
</h3>
<p className="text-muted-foreground text-sm">
步骤条执行过程中模型有时会先吐出一段正文再发起工具调用或开始下一轮思考。
开启后,本轮所有过程正文会合并成一条,集中显示在步骤条下方(步骤条保持
一个整体不被拆开,最终回答紧随其后);大模型响应中断触发后台自动重试时,
重试前已流出的半截回答同样保留。关闭则恢复原有行为——工具调用到达或
重试开始时清空未完成的正文。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">保留步骤条执行中生成的正文</div>
<p className="text-muted-foreground text-sm">
仅影响问答页的显示,不改变后台执行与落库逻辑;重试保留的半截内容
刷新页面后不会保留。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="自动重试时保留已生成的正文"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取保留中间正文开关,系统将按开启处理。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后无需重启后台,对下一次触发的自动重试立即生效;正在生成中的回答不会中途切换。
</p>
</section>
);
}
@@ -0,0 +1,268 @@
"use client";
import { Clock3Icon, FileOutputIcon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useRoundtableRoomLimitSettings,
useUpdateRoundtableRoomLimitSettings,
useWriteFileCompatibilitySettings,
} from "@/core/system-settings/hooks";
import { loadRoundtableRoomSummary } from "@/roundtable-planning/api/rooms";
export function RoundtableRoomSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useRoundtableRoomLimitSettings();
const update = useUpdateRoundtableRoomLimitSettings();
const globalFileStream = useWriteFileCompatibilitySettings();
const enabled = data?.enabled ?? true;
const fileStreamEnabled = data?.write_file_compatibility_enabled ?? false;
const globalFileStreamEnabled = globalFileStream.data?.enabled ?? false;
const [maxRooms, setMaxRooms] = useState(String(data?.max_rooms ?? 3));
const [maxHoldMinutes, setMaxHoldMinutes] = useState(
String(data?.max_hold_minutes ?? 30),
);
const [maxCycles, setMaxCycles] = useState(String(data?.max_cycles ?? 8));
const [activeCount, setActiveCount] = useState<number | null>(null);
useEffect(() => {
if (data?.max_rooms != null) setMaxRooms(String(data.max_rooms));
}, [data?.max_rooms]);
useEffect(() => {
if (data?.max_hold_minutes != null) {
setMaxHoldMinutes(String(data.max_hold_minutes));
}
}, [data?.max_hold_minutes]);
useEffect(() => {
if (data?.max_cycles != null) setMaxCycles(String(data.max_cycles));
}, [data?.max_cycles]);
useEffect(() => {
let cancelled = false;
const load = () => {
void loadRoundtableRoomSummary()
.then((summary) => {
if (!cancelled) setActiveCount(summary.activeCount);
})
.catch(() => {
if (!cancelled) setActiveCount(null);
});
};
load();
const timer = window.setInterval(load, 15000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
const save = async (next: {
enabled?: boolean;
max_rooms?: number;
max_hold_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}) => {
try {
await update.mutateAsync(next);
toast.success("会商运行设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const saveMaxRooms = () => {
const parsed = Number(maxRooms);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 20) {
toast.error("房间上限请填写 1 到 20 的整数");
return;
}
void save({ max_rooms: parsed });
};
const saveMaxHoldMinutes = () => {
const parsed = Number(maxHoldMinutes);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1440) {
toast.error("最长占用时间请填写 1 到 1440 分钟的整数");
return;
}
void save({ max_hold_minutes: parsed });
};
const saveMaxCycles = () => {
const parsed = Number(maxCycles);
if (!Number.isInteger(parsed) || parsed < 2 || parsed > 100) {
toast.error("总控收口轮数请填写 2 到 100 的整数");
return;
}
void save({ max_cycles: parsed });
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<UsersIcon className="size-5" />
会商并发与收口
</h3>
<p className="text-muted-foreground text-sm">
配置第二步的全站并发名额、最长占用时间和总控强制收口轮数。所有账号共用房间名额;仅打开页面或查看历史记录不占用。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">第二步房间上限</div>
<p className="text-muted-foreground text-sm">
开启后,全站第二步房间达到上限时,新的会商会停留在第一步并提示稍后再试。已获准进入的会商可正常暂停和继续。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={(checked) => void save({ enabled: checked })}
aria-label="启用会商第二步房间上限"
/>
)}
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="font-medium">同时进行的房间数</span>
<p className="text-muted-foreground text-sm">默认 3,可改为 1 到 20。保存后立即生效,无需重启。</p>
<input
type="number"
min={1}
max={20}
value={maxRooms}
disabled={isError || update.isPending}
onChange={(event) => setMaxRooms(event.target.value)}
className="border-input bg-background mt-2 h-9 w-24 rounded-md border px-3 text-sm"
aria-label="会商房间上限"
/>
</label>
<Button type="button" size="sm" disabled={isError || update.isPending} onClick={saveMaxRooms}>
保存上限
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="flex items-center gap-2 font-medium">
<Clock3Icon className="size-4" />
单个房间最长占用时间
</span>
<p className="text-muted-foreground text-sm">
默认 30 分钟,可改为 1 到 1440 分钟。到时会自动停止第二步并释放名额,避免异常任务长期占用。
</p>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={1}
max={1440}
value={maxHoldMinutes}
disabled={isError || update.isPending}
onChange={(event) => setMaxHoldMinutes(event.target.value)}
className="border-input bg-background h-9 w-24 rounded-md border px-3 text-sm"
aria-label="会商房间最长占用分钟数"
/>
<span className="text-muted-foreground text-sm">分钟</span>
</div>
</label>
<Button
type="button"
size="sm"
disabled={isError || update.isPending}
onClick={saveMaxHoldMinutes}
>
保存时长
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="flex items-center gap-2 font-medium">
<ListOrderedIcon className="size-4" />
总控强制收口轮数
</span>
<p className="text-muted-foreground text-sm">
用于总控自由派活模式,默认第 8 轮,可改为第 2 到 100 轮。到达该轮时不会停止会商,而是关闭总控工具并输出最终总结;业务链与 DAG 在既定席位完成后自然收口。保存后从下一次研讨开始生效。
</p>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={2}
max={100}
value={maxCycles}
disabled={isError || update.isPending}
onChange={(event) => setMaxCycles(event.target.value)}
className="border-input bg-background h-9 w-24 rounded-md border px-3 text-sm"
aria-label="总控强制收口轮数"
/>
<span className="text-muted-foreground text-sm">轮</span>
</div>
</label>
<Button
type="button"
size="sm"
disabled={isError || update.isPending}
onClick={saveMaxCycles}
>
保存轮数
</Button>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="flex items-center gap-2 font-medium">
<FileOutputIcon className="size-4" />
会商文件流式兼容
</div>
<p className="text-muted-foreground text-sm">
会商生成文件并打开沙箱时使用兼容写入。全局「文件流式兼容」开着时,会商自动使用;全局关着时,打开本开关后只有会商使用,普通对话保持原来的写入方式。
</p>
<p className="text-muted-foreground text-xs">
{globalFileStreamEnabled
? "当前会商会使用兼容写入,因为全局开关已开启。"
: fileStreamEnabled
? "当前只有会商使用兼容写入。"
: "当前会商使用各模型原来的写入方式。"}
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={fileStreamEnabled}
disabled={isError || update.isPending}
onCheckedChange={(checked) =>
void save({ write_file_compatibility_enabled: checked })
}
aria-label="会商启用文件流式兼容"
/>
)}
</div>
<p className="text-muted-foreground text-sm">
当前全站占用:{activeCount == null ? "暂时无法读取" : `${activeCount} / ${data?.max_rooms ?? maxRooms}`}
</p>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">无法读取会商房间设置,本页暂不能修改。</p>
<Button type="button" variant="outline" size="sm" disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
</section>
);
}
@@ -1,16 +1,23 @@
"use client";
import {
BellIcon,
BrainIcon,
CpuIcon,
FileOutputIcon,
PaletteIcon,
SparklesIcon,
UploadCloudIcon,
UserIcon,
WrenchIcon,
} from "lucide-react";
import {
BellIcon,
BrainIcon,
CpuIcon,
DatabaseIcon,
ArchiveIcon,
FileOutputIcon,
HistoryIcon,
PaletteIcon,
SearchIcon,
ShrinkIcon,
SparklesIcon,
TypeIcon,
UploadCloudIcon,
UserIcon,
UsersIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -23,24 +30,44 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-settings-page";
import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page";
import { ApiReadCacheSettingsPage } from "@/components/workspace/settings/api-read-cache-settings-page";
import { ChatArchiveSettingsPage } from "@/components/workspace/settings/chat-archive-settings-page";
import { RoundtableRoomSettingsPage } from "@/components/workspace/settings/roundtable-room-settings-page";
import { RetryKeepPartialSettingsPage } from "@/components/workspace/settings/retry-keep-partial-settings-page";
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
import { ServerDiagnosticsPanel } from "@/pages/ServerDiagnosticsPage";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "fileWriting"
| "uploads"
| "tools"
| "encoder"
| "about";
| "conversation-context"
| "model-prompts"
| "server-diagnostics"
| "api-read-cache"
| "worker-pool"
| "chat-archive"
| "roundtable-rooms"
| "knowledge-retrieval"
| "appIdentity"
| "memory"
| "fileWriting"
| "retryKeepPartial"
| "uploads"
| "tools"
| "encoder"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
defaultSection?: SettingsSection;
@@ -48,8 +75,8 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -75,17 +102,27 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
...(isAdmin
? [
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
...(isAdmin
? [
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
{ id: "model-prompts", label: "模型追加提示词", icon: SparklesIcon },
{ id: "server-diagnostics", label: "服务器诊断", icon: CpuIcon },
{ id: "api-read-cache", label: "Redis 与性能", icon: DatabaseIcon },
{ id: "worker-pool", label: "执行节点管理", icon: CpuIcon },
{ id: "chat-archive", label: "聊天记录归档", icon: ArchiveIcon },
{ id: "roundtable-rooms", label: "会商并发", icon: UsersIcon },
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
{ id: "appIdentity", label: "系统名称", icon: TypeIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
{
id: "account",
label: "个人信息",
@@ -97,8 +134,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.about,
isAdmin,
t.settings.sections.about,
isAdmin,
],
);
return (
@@ -144,24 +181,54 @@ export function SettingsDialog(props: SettingsDialogProps) {
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && (
<AppearanceSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
{activeSection === "server-diagnostics" && isAdmin && (
<ServerDiagnosticsPanel />
)}
{activeSection === "api-read-cache" && isAdmin && (
<ApiReadCacheSettingsPage />
)}
{activeSection === "worker-pool" && isAdmin && (
<WorkerPoolSettingsPage />
)}
{activeSection === "chat-archive" && isAdmin && (
<ChatArchiveSettingsPage />
)}
{activeSection === "appearance" && (
<AppearanceSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "conversation-context" && isAdmin && (
<ContextWindowSettingsPage />
)}
{activeSection === "model-prompts" && isAdmin && (
<ModelPromptSettingsPage />
)}
{activeSection === "roundtable-rooms" && isAdmin && (
<RoundtableRoomSettingsPage />
)}
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "appIdentity" && isAdmin && (
<AppIdentitySettingsPage />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "retryKeepPartial" && isAdmin && (
<RetryKeepPartialSettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && (
<LocalEncoderSettingsCard />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && (
<LocalEncoderSettingsCard />
)}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -558,6 +558,7 @@ function SkillSettingsList({
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
searchPlaceholder="搜索名称 / 中文名 / 描述 / 详情 / 标签,多关键词空格分隔"
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
@@ -36,8 +36,10 @@ import { SettingsSection } from "./settings-section";
export function ToolSettingsPage() {
const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig();
const isAdmin = getStoredAuth()?.system_role === "admin";
// MCP configuration is an admin-only API. Keep ordinary users from making
// a request that will always return 403, and show an explicit access state.
const { config, isLoading, error } = useMCPConfig(isAdmin);
const isStaticMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const [editOpen, setEditOpen] = useState(false);
@@ -74,7 +76,14 @@ export function ToolSettingsPage() {
title={t.settings.tools.title}
description={t.settings.tools.description}
>
{isLoading ? (
{!isAdmin ? (
<div
role="status"
className="text-muted-foreground rounded-md border p-6 text-center text-sm"
>
MCP 工具由管理员统一配置和管理。如需调整,请联系管理员。
</div>
) : isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div className="text-destructive text-sm">Error: {error.message}</div>
@@ -102,19 +111,23 @@ export function ToolSettingsPage() {
</div>
)}
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
{isAdmin && (
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
)}
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
{isAdmin && (
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
)}
</SettingsSection>
);
}
@@ -0,0 +1,166 @@
import { Link } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
type Settings = {
enabled: boolean; transport: "ssh" | "socket"; host: string; port: number;
username: string; host_key_sha256: string; source_container: string; network: string;
python_executable: string; working_dir: string;
database_pool_size: number; postgres_pool_size: number; database_connection_budget: number;
desired: number; concurrency: number; queue_limit: number; queue_timeout: number;
cpu: number; memory_mb: number; total_cpu: number; total_memory_mb: number;
password_set: boolean;
};
type Node = { name: string; ready: boolean; active: number; capacity?: number; draining: boolean; error?: string };
type Discovery = { cpus: number; memory_mb: number; note: string; containers: { name: string; networks: string[] }[]; recommendation: Partial<Settings> & { desired: number } };
type Status = { settings: Settings; nodes: Node[]; error: string | null };
async function call<T>(path = "", method = "GET", body?: unknown): Promise<T> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/worker-pool${path}`, {
method, headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : `请求失败(${response.status})`);
return data as T;
}
export function WorkerPoolSettingsPage() {
const [status, setStatus] = useState<Status>();
const [draft, setDraft] = useState<Settings>();
const [password, setPassword] = useState("");
const [discovery, setDiscovery] = useState<Discovery>();
const [fingerprint, setFingerprint] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const lastServerPython = useRef<string | undefined>(undefined);
useEffect(() => {
let disposed = false;
const refresh = async () => {
try {
const value = await call<Status>();
if (!disposed) {
const previousPython = lastServerPython.current;
setStatus(value);
setDraft((old) => {
if (!old) return value.settings;
if (previousPython !== undefined && old.python_executable === previousPython) {
return { ...old, python_executable: value.settings.python_executable };
}
return old;
});
lastServerPython.current = value.settings.python_executable;
setError("");
}
} catch (e) { if (!disposed) setError(e instanceof Error ? e.message : "读取失败"); }
};
void refresh();
const timer = setInterval(() => void refresh(), 5000);
return () => { disposed = true; clearInterval(timer); };
}, []);
const update = (key: keyof Settings, value: string | number | boolean) => setDraft((old) => old ? { ...old, [key]: value } : old);
const save = async () => {
if (!draft) return;
setBusy(true);
try {
const { password_set: _passwordSet, ...settings } = draft;
const value = await call<Status>("", "PUT", { ...settings, password: password || null });
setStatus(value); setDraft(value.settings); setPassword("");
toast.success("设置已保存,系统正在调整执行节点");
} catch (e) { toast.error(e instanceof Error ? e.message : "保存失败"); }
finally { setBusy(false); }
};
const inspectServer = async () => {
if (!draft) return;
setBusy(true);
try {
const { password_set: _passwordSet, ...settings } = draft;
const result = await call<Discovery>("/discover", "POST", { ...settings, password: password || null });
setDiscovery(result);
toast.success("服务器资源已读取,请查看推荐配置");
} catch (e) { toast.error(e instanceof Error ? e.message : "检测失败"); }
finally { setBusy(false); }
};
const readFingerprint = async () => {
if (!draft) return;
setBusy(true);
try {
const result = await call<{ fingerprint: string }>("/fingerprint", "POST", { host: draft.host, port: draft.port });
setFingerprint(result.fingerprint);
} catch (e) { toast.error(e instanceof Error ? e.message : "读取失败"); }
finally { setBusy(false); }
};
const test = async () => {
setBusy(true);
try { toast.success((await call<{ message: string }>("/test", "POST")).message); }
catch (e) { toast.error(e instanceof Error ? e.message : "连接失败"); }
finally { setBusy(false); }
};
return <section className="space-y-5">
<Button variant="outline" asChild><Link to="/page/workspace/admin/server-diagnostics" target="_blank" rel="noreferrer">服务器诊断与日志 ↗</Link></Button>
<div><h3 className="text-lg font-semibold">执行节点管理</h3>
<p className="text-muted-foreground text-sm">在同一台 Docker 服务器上动态扩容普通问答和智能体问答。定时任务、会商、研究和工作流继续由主服务统一执行。</p></div>
{error && <p role="alert" className="text-destructive">{error}</p>}
{!draft ? <p>正在读取配置…</p> : <>
<label className="flex items-center gap-3"><Switch checked={draft.enabled} disabled={busy} onCheckedChange={(v) => update("enabled", v)} />启用在线 Worker 池</label>
<label className="block space-y-1"><span>服务器连接方式</span><select className="w-full rounded border bg-background p-2" value={draft.transport} onChange={(e) => update("transport", e.target.value)}>
<option value="ssh">SSH 账号密码(主容器与 Worker 在同一 Docker 网络)</option>
<option value="socket">本机 Docker Socket(需要预先挂载)</option>
</select></label>
<div className="grid gap-4 sm:grid-cols-2">
{draft.transport === "ssh" && <>
{([ ["host", "服务器 IP / 主机名"], ["username", "SSH 账号"], ["host_key_sha256", "SSH 主机密钥指纹(SHA256:…)"] ] as const).map(([key, label]) => <label className="space-y-1" key={key}><span>{label}</span><Input value={draft[key]} onChange={(e) => { update(key, e.target.value); setFingerprint(""); setDiscovery(undefined); }} /></label>)}
<label className="space-y-1"><span>SSH 端口</span><Input type="number" min={1} max={65535} value={draft.port} onChange={(e) => update("port", Number(e.target.value))} /></label>
<label className="space-y-1"><span>SSH 密码{draft.password_set ? "(已设置,留空保留)" : ""}</span><Input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
</>}
{([ ["source_container", "主服务容器名称"], ["network", "Docker 网络名称"], ["python_executable", "镜像内 Python 路径"], ["working_dir", "镜像内后端目录"] ] as const).map(([key, label]) => <label className="space-y-1" key={key}><span>{label}</span><Input value={draft[key]} onChange={(e) => update(key, e.target.value)} /></label>)}
{([ ["desired", "期望 Worker 数量", 0, 32], ["concurrency", "每节点同时执行数", 1, 32], ["queue_limit", "每节点最大排队数", 1, 1024], ["queue_timeout", "最长排队秒数", 1, 300], ["cpu", "每节点 CPU 核数", 0.25, 64], ["memory_mb", "每节点内存 MB", 512, 131072], ["total_cpu", "Worker 总 CPU 预算", 0.25, 512], ["total_memory_mb", "Worker 总内存预算 MB", 512, 1048576] ] as const).map(([key, label, min, max]) => <label className="space-y-1" key={key}><span>{label}</span><Input type="number" min={min} max={max} step={key.includes("cpu") ? 0.25 : 1} value={draft[key]} onChange={(e) => update(key, Number(e.target.value))} /></label>)}
</div>
<div className="rounded border p-4 space-y-3">
<h4 className="font-medium">连接检测与资源推荐</h4>
{draft.transport === "ssh" && <>
<p className="text-sm text-muted-foreground">账号和密码用于登录。主机指纹用来识别服务器,不是密码。先读取并确认指纹,再连接检测。</p>
<Button variant="outline" disabled={busy || !draft.host} onClick={() => void readFingerprint()}>读取服务器指纹</Button>
{fingerprint && <div className="space-y-2"><p className="break-all text-sm">本次读取:{fingerprint}</p>
{draft.host_key_sha256 && draft.host_key_sha256 !== fingerprint && <p className="text-destructive text-sm">指纹与已保存的服务器不同,请核实服务器是否更换。</p>}
<Button variant="outline" disabled={busy} onClick={() => { update("host_key_sha256", fingerprint); setFingerprint(""); }}>确认使用此服务器指纹</Button></div>}
</>}
<Button variant="outline" disabled={busy || (draft.transport === "ssh" && !draft.host_key_sha256)} onClick={() => void inspectServer()}>连接并读取资源</Button>
{discovery && <div className="space-y-3 text-sm">
<p>服务器:{discovery.cpus} 核 CPU · {(discovery.memory_mb / 1024).toFixed(1)} GB 内存</p>
<p>{discovery.note}</p>
<label className="block space-y-1"><span>选择当前系统的主服务容器</span><select className="w-full rounded border bg-background p-2" value={draft.source_container} onChange={(e) => {
const container = discovery.containers.find((c) => c.name === e.target.value);
update("source_container", e.target.value); if (container?.networks.length === 1) update("network", container.networks[0]!);
}}><option value="">请选择</option>{discovery.containers.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}</select></label>
<label className="block space-y-1"><span>主服务与 Worker 共用网络</span><select className="w-full rounded border bg-background p-2" value={draft.network} onChange={(e) => update("network", e.target.value)}><option value="">请选择</option>{discovery.containers.find((c) => c.name === draft.source_container)?.networks.map((n) => <option key={n} value={n}>{n}</option>)}</select></label>
{discovery.recommendation.desired > 0 ? <>
<p>推荐起步:{discovery.recommendation.desired} 个 Worker,每个 {discovery.recommendation.cpu} 核 / {discovery.recommendation.memory_mb} MB,同时执行 {discovery.recommendation.concurrency} 个问答,超出后排队。</p>
<Button variant="outline" disabled={busy || !!status?.nodes.length} onClick={() => setDraft((old) => old ? { ...old, ...discovery.recommendation } : old)}>填入推荐配置</Button>
{!!status?.nodes.length && <p>修改单节点资源前,先将期望数量设为 0,保存并等待现有节点完成任务退出。</p>}
</> : <p className="text-destructive">预留资源后不足以启动节点,请调整服务器资源或其他服务的配额。</p>}
</div>}
<p className="text-sm text-muted-foreground">检测不会创建容器。保存并应用后生效;账号密码加密保存到服务器持久化目录,重启后保留设置并按期望数量恢复节点。</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{([ ["database_pool_size", "每节点业务数据库连接上限", 1, 32], ["postgres_pool_size", "每节点检查点 / Store 各自连接上限", 1, 32], ["database_connection_budget", "全部 Worker 数据库连接预算", 3, 1024] ] as const).map(([key, label, min, max]) => <label className="space-y-1" key={key}><span>{label}</span><Input type="number" min={min} max={max} value={draft[key]} onChange={(e) => update(key, Number(e.target.value))} /></label>)}
</div>
<p className="text-muted-foreground text-sm">Worker 连接额度合计:{draft.desired * (draft.database_pool_size + 2 * draft.postgres_pool_size)}。预算需从数据库总连接数中扣除主服务及其他应用所需连接。</p>
<p className="text-muted-foreground text-sm">先读取指纹、检测资源并填写配置,再启用保存。资源预算需为主服务、数据库及操作系统保留余量。修改单节点配置前,将数量设为 0 并等待节点退出。</p>
<p className="text-muted-foreground text-sm">创建 Worker 前,系统会在所选主容器内验证 Python 3.12+、uvicorn 和 Worker 入口;默认路径不可用时会自动查找并保存可用路径。无需使用宿主机 Python 或联网下载。</p>
<p className="text-muted-foreground text-sm">主机密钥指纹可通过上方按钮读取并确认。账号需要运行 Docker 的权限。本机 Socket 需在主容器挂载 /var/run/docker.sock:/var/run/docker.sock;Worker 不继承该挂载。源容器的数据目录和配置必须已持久化挂载;聊天存储支持 PostgreSQL 或 SQLite,沿用现有配置;SQLite 必须共享本机数据目录。控制器私有目录需要独立持久化,不能与 Worker 共享。</p>
<div className="flex gap-3"><Button disabled={busy || !!error} onClick={() => void save()}>保存并应用</Button><Button variant="outline" disabled={busy || !!error} onClick={() => void test()}>测试已保存的连接</Button></div>
</>}
{status?.error && <p role="alert" className="text-destructive">{status.error}</p>}
<div className="space-y-2"><h4 className="font-medium">执行节点(每 5 秒更新)</h4>
{status?.nodes.length === 0 && <p className="text-muted-foreground text-sm">暂无节点。启用后按期望数量创建。</p>}
{status?.nodes.map((node) => <div className="rounded border p-3 text-sm" key={node.name}><div className="flex justify-between gap-2"><span>{node.name}</span><span>{node.draining ? "停止接单,等待完成" : node.ready ? "就绪" : "等待就绪"}</span></div><p>执行与排队:{node.active} / 同时执行额度:{node.capacity ?? "—"}</p>{node.error && <p className="text-destructive">{node.error}</p>}</div>)}
</div>
</section>;
}
@@ -9,7 +9,10 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
resolveWeKnoraSourceTarget,
type WeKnoraSourceTarget,
} from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown";
@@ -56,6 +59,9 @@ export function SourceCard({
}) {
const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(
null,
);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({
@@ -65,7 +71,13 @@ export function SourceCard({
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const isWikiIndexSource =
source.type === "llmwiki_wiki_index_intro" ||
source.raw?.type === "llmwiki_wiki_index_intro";
const isWikiSource =
Boolean(previewTarget?.wikiSlug) ||
source.type === "llmwiki_wiki" ||
source.type === "llmwiki_wiki_page";
const matchedHeading = isWikiSource
? String(
(source.raw?.matched_heading as string | undefined) ||
@@ -78,7 +90,7 @@ export function SourceCard({
const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary
: "";
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string"
? source.raw.rank_score_semantics
: "";
@@ -155,7 +167,7 @@ export function SourceCard({
</p>
)}
</div>
{(source.url || isWikiSource) && (
{(source.url || isWikiSource || isWikiIndexSource) && (
previewTarget ? (
<span className={cn(
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
@@ -166,25 +178,39 @@ export function SourceCard({
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
{isWikiSource ? "Wiki 文档" : "知识库文档"}
</span>
) : isWikiIndexSource ? (
<span className="mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md bg-emerald-100 px-1.5 py-1 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenTextIcon className="size-3.5" />
Wiki 索引
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)
)}
</div>
{previewTarget && (
{(previewTarget || isWikiIndexSource) && (
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
<span>
{isWikiIndexSource
? "点击卡片查看完整 Wiki 索引"
: isWikiSource
? "点击卡片查看完整 Wiki"
: "点击卡片查看详情"}
</span>
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
)}
</div>
{previewTarget && (
{(previewTarget || linkedTarget) && (
<WeKnoraSourceDrawer
open={detailOpen}
target={previewTarget}
target={linkedTarget ?? previewTarget}
title={source.title}
quote={content}
onOpenChange={setDetailOpen}
onOpenChange={(open) => {
setDetailOpen(open);
if (!open) setLinkedTarget(null);
}}
/>
)}
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
@@ -246,6 +272,18 @@ export function SourceCard({
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
linkClassName={(href) =>
href.startsWith("/weknora-source-preview?")
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
: undefined
}
onLinkClick={(event, href) => {
const target = resolveWeKnoraSourceTarget({ url: href });
if (!target) return;
event.preventDefault();
setLinkedTarget(target);
setDetailOpen(true);
}}
/>
</article>
</>
@@ -27,7 +27,7 @@ const PHASE_LABELS: Record<string, string> = {
writing_sources: "上传知识资料",
generating_wiki: "分析并生成分类 Wiki",
vectorizing_wiki: "Wiki 向量化",
syncing_assistant: "同步知识梳理总库",
syncing_assistant: "同步检索索引",
activating: "激活新版本",
completed: "完成",
};
@@ -219,14 +219,14 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora"
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
? "当前未配置普通知识库地址;请先配置知识服务地址,再归纳技能内容。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p>
)}
</div>
</div>
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及所需索引同步均在后台完成。
</div>
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
@@ -2,6 +2,7 @@ import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
@@ -20,6 +21,7 @@ export function ThreadTitle({
thread: BaseStream<AgentThreadState>;
}) {
const { t } = useI18n();
const appName = useAppSystemName(t.pages.appName);
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
@@ -32,15 +34,15 @@ export function ThreadTitle({
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`;
document.title = `Loading... - ${appName}`;
} else {
document.title = `${_title} - ${t.pages.appName}`;
document.title = `${_title} - ${appName}`;
}
}, [
appName,
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
@@ -7,6 +7,20 @@ import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
import {
coordinatesMatch,
curvedLineCoordinates,
flowArrowsForLines,
initialMapCamera,
overlayPadding,
resolveLineColor,
resolveMarkerColor,
shortenProjectedPath,
svgPathFromPoints,
type FlowLine,
type MapCamera,
type MapPoint,
} from "./map-style";
import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card";
@@ -33,31 +47,162 @@ function allCoordinates(spec: MapSpec): Array<[number, number]> {
];
}
function defaultCenter(spec: MapSpec): [number, number] {
if (spec.center) return spec.center;
const coordinates = allCoordinates(spec);
if (coordinates.length === 0) return [104.1954, 35.8617];
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
return [total[0] / coordinates.length, total[1] / coordinates.length];
function bootCamera(camera: MapCamera): { center: MapPoint; zoom: number } {
if (camera.kind === "center") return { center: camera.center, zoom: camera.zoom };
return {
center: [(camera.sw[0] + camera.ne[0]) / 2, (camera.sw[1] + camera.ne[1]) / 2],
zoom: 4,
};
}
function applyMapCamera(map: maplibregl.Map, camera: MapCamera, padding: number) {
if (camera.kind === "center") {
map.jumpTo({ center: camera.center, zoom: camera.zoom });
return;
}
map.fitBounds([camera.sw, camera.ne], { padding, maxZoom: camera.maxZoom, duration: 0 });
}
function prefersReducedMotion(): boolean {
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}
function markerColorLookup(spec: MapSpec): Map<string, string> {
const colors = new Map<string, string>();
spec.markers.forEach((marker, index) => {
colors.set(marker.name, resolveMarkerColor(index, marker.color));
});
return colors;
}
function styledLines(spec: MapSpec): FlowLine[] {
const markerColors = markerColorLookup(spec);
return spec.lines.map((line, index) => {
const start = line.coordinates[0];
const startMarker = start
? spec.markers.find((marker) => coordinatesMatch(marker.coordinate, start))
: undefined;
const fromColor = startMarker ? markerColors.get(startMarker.name) : undefined;
return {
coordinates: line.coordinates,
color: resolveLineColor(index, line.color, fromColor),
name: line.name,
arrow: line.arrow,
flow: line.flow,
};
});
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
function projectPoint(map: maplibregl.Map, point: MapPoint): { x: number; y: number } {
const projected = map.project({ lng: point[0], lat: point[1] });
return { x: projected.x, y: projected.y };
}
function paintMapOverlay(
svg: SVGSVGElement,
map: maplibregl.Map,
lines: FlowLine[],
markers: Array<{ coordinate: MapPoint; color: string; name: string }>,
progress: number,
reducedMotion: boolean,
) {
const root = map.getContainer();
const width = root.clientWidth || 1;
const height = root.clientHeight || 1;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
const arrowDefs = lines
.filter((line) => line.arrow !== false)
.map((line) => {
const id = `df-map-arrow-${line.color.slice(1)}`;
return `<marker id="${id}" markerWidth="8" markerHeight="8" refX="6.4" refY="4" orient="auto" markerUnits="userSpaceOnUse"><path d="M0.6,1.1 L7.2,4 L0.6,6.9 Z" fill="${line.color}" stroke="#fff" stroke-width="0.6" stroke-linejoin="round"/></marker>`;
})
.filter((value, index, all) => all.indexOf(value) === index)
.join("");
const paths = lines
.map((line) => {
const projected = curvedLineCoordinates(line.coordinates).map((point) => projectPoint(map, point));
const d = svgPathFromPoints(shortenProjectedPath(projected, 10, 13));
if (!d) return "";
const markerId = `df-map-arrow-${line.color.slice(1)}`;
const end = line.arrow !== false ? ` marker-end="url(#${markerId})"` : "";
const flow =
line.arrow !== false && line.flow !== false && !reducedMotion
? `<path d="${d}" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-dasharray="6 10" class="df-map-line-flow"/>`
: "";
return `<path d="${d}" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.88"/>
<path d="${d}" fill="none" stroke="${line.color}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"${end}/>
${flow}`;
})
.join("");
const arrows = flowArrowsForLines(lines, progress, { reducedMotion })
.map((arrow) => {
const { x, y } = projectPoint(map, arrow.point);
return `<g transform="translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${arrow.bearing.toFixed(1)})"><path d="M0,-4.5 L3.6,4.2 L0,2 L-3.6,4.2 Z" fill="${arrow.color}" stroke="#fff" stroke-width="0.7" stroke-linejoin="round"/></g>`;
})
.join("");
const dots = markers
.map((marker, index) => {
const { x, y } = projectPoint(map, marker.coordinate);
const label = escapeHtml(marker.name);
return `<g class="df-map-marker" data-marker-index="${index}" transform="translate(${x.toFixed(1)} ${y.toFixed(1)})" style="cursor:pointer;pointer-events:auto">
<title>${label}</title>
<circle r="16" fill="transparent"/>
<circle r="8" fill="${marker.color}" stroke="#fff" stroke-width="2.5" paint-order="stroke" style="filter:drop-shadow(0 1px 3px rgba(15,23,42,0.45))"/>
</g>`;
})
.join("");
svg.innerHTML = `<defs>${arrowDefs}</defs>${paths}${arrows}${dots}`;
}
export default function MapRenderer({ spec }: { spec: MapSpec }) {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<SVGSVGElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
const lines = useMemo(() => styledLines(spec), [spec]);
const markerColors = useMemo(
() => spec.markers.map((marker, index) => resolveMarkerColor(index, marker.color)),
[spec],
);
useEffect(() => {
const container = containerRef.current;
const overlay = overlayRef.current;
if (!container) return;
setTileFailed(false);
const camera = initialMapCamera(coordinates, { center: spec.center, zoom: spec.zoom });
const boot = bootCamera(camera);
const overlayMarkers = spec.markers.map((marker, index) => ({
coordinate: marker.coordinate,
color: markerColors[index] ?? resolveMarkerColor(index, marker.color),
name: marker.name,
}));
const map = new maplibregl.Map({
container,
style: createStyle(),
center: defaultCenter(spec),
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
center: boot.center,
zoom: boot.zoom,
minZoom: 1,
maxZoom: 20,
attributionControl: { compact: true },
@@ -65,18 +210,59 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
mapRef.current = map;
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
const markers: maplibregl.Marker[] = [];
for (const marker of spec.markers) {
const element = document.createElement("button");
element.type = "button";
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
element.title = marker.name;
element.setAttribute("aria-label", marker.name);
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
);
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
}
let raf = 0;
let lastPaint = 0;
let alive = true;
let viewReady = false;
let applyingView = false;
let popup: maplibregl.Popup | null = null;
const motionQuery = window.matchMedia?.("(prefers-reduced-motion: reduce)");
let reducedMotion = prefersReducedMotion();
const redrawOverlay = (progress = (performance.now() / 4000) % 1) => {
if (!alive || !overlay) return;
paintMapOverlay(overlay, map, lines, overlayMarkers, reducedMotion ? 0.72 : progress, reducedMotion);
};
const onMove = () => redrawOverlay();
map.on("move", onMove);
map.on("resize", onMove);
const tick = (now: number) => {
if (!alive) return;
const flowing = !reducedMotion && lines.some((line) => line.arrow !== false && line.flow !== false);
if (flowing && now - lastPaint >= 32) {
lastPaint = now;
redrawOverlay((now / 4000) % 1);
}
raf = requestAnimationFrame(tick);
};
const onMotionChange = () => {
reducedMotion = prefersReducedMotion();
redrawOverlay();
};
motionQuery?.addEventListener("change", onMotionChange);
const openMarkerPopup = (index: number) => {
const marker = spec.markers[index];
if (!marker) return;
popup?.remove();
popup = new maplibregl.Popup({ offset: 16, closeButton: true })
.setLngLat(marker.coordinate)
.setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
)
.addTo(map);
};
const onOverlayClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target.closest("[data-marker-index]") : null;
if (!target) return;
event.stopPropagation();
openMarkerPopup(Number(target.getAttribute("data-marker-index")));
};
overlay?.addEventListener("click", onOverlayClick);
let baseTileLoaded = false;
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
@@ -91,70 +277,60 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
map.on("sourcedata", onSourceData);
map.on("error", onError);
map.on("load", () => {
if (spec.lines.length > 0) {
map.addSource("visual-lines", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.lines.map((line) => ({
type: "Feature",
properties: { name: line.name ?? "" },
geometry: { type: "LineString", coordinates: line.coordinates },
})),
},
});
map.addLayer({
id: "visual-lines",
type: "line",
source: "visual-lines",
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
});
}
const applyView = () => {
if (!alive) return false;
map.resize();
const width = container.clientWidth;
const height = container.clientHeight;
if (width < 80 || height < 80) return false;
if (!map.loaded() && !map.isStyleLoaded()) return false;
applyMapCamera(map, camera, overlayPadding(width, height));
redrawOverlay();
return true;
};
if (spec.polygons.length > 0) {
map.addSource("visual-polygons", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.polygons.map((polygon) => ({
type: "Feature",
properties: { name: polygon.name ?? "" },
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
})),
},
});
map.addLayer({
id: "visual-polygons-fill",
type: "fill",
source: "visual-polygons",
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
});
map.addLayer({
id: "visual-polygons-border",
type: "line",
source: "visual-polygons",
paint: { "line-color": "#2563eb", "line-width": 2 },
});
const ensureView = () => {
if (!alive || applyingView) return;
if (viewReady) {
map.resize();
redrawOverlay();
return;
}
applyingView = true;
const fitted = applyView();
applyingView = false;
if (!fitted) return;
viewReady = true;
map.once("idle", () => {
if (!alive) return;
map.resize();
redrawOverlay();
});
raf = requestAnimationFrame(tick);
};
if (!spec.center && coordinates.length > 1) {
const bounds = coordinates.reduce(
(current, coordinate) => current.extend(coordinate),
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
);
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
}
});
map.on("load", ensureView);
map.once("idle", ensureView);
const resizeObserver = new ResizeObserver(ensureView);
resizeObserver.observe(container);
requestAnimationFrame(ensureView);
return () => {
alive = false;
cancelAnimationFrame(raf);
resizeObserver.disconnect();
motionQuery?.removeEventListener("change", onMotionChange);
overlay?.removeEventListener("click", onOverlayClick);
popup?.remove();
map.off("move", onMove);
map.off("resize", onMove);
map.off("load", ensureView);
map.off("sourcedata", onSourceData);
map.off("error", onError);
markers.forEach((marker) => marker.remove());
mapRef.current = null;
map.remove();
};
}, [coordinates, reloadToken, spec]);
}, [coordinates, lines, markerColors, reloadToken, spec]);
return (
<VisualizationCard>
@@ -165,6 +341,18 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
</div>
<div className="relative">
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
<style>{`
.df-map-line-flow { animation: df-map-line-flow 1.2s linear infinite; }
@keyframes df-map-line-flow { to { stroke-dashoffset: -32; } }
@media (prefers-reduced-motion: reduce) {
.df-map-line-flow { animation: none; }
}
`}</style>
<svg
ref={overlayRef}
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-[1] h-[420px] w-full overflow-visible"
/>
{tileFailed ? (
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
@@ -185,30 +373,46 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
</div>
) : null}
</div>
{spec.markers.length > 0 ? (
{spec.markers.length > 0 || spec.lines.length > 0 ? (
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看地点列表</summary>
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`}>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</li>
))}
</ul>
{spec.markers.length > 0 ? (
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`} className="flex items-start gap-2">
<span
aria-hidden="true"
className="mt-0.5 inline-block size-2.5 shrink-0 rounded-full border border-black/10"
style={{ background: markerColors[index] }}
/>
<span>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</span>
</li>
))}
</ul>
) : null}
{spec.lines.length > 0 ? (
<ul className="mt-2 space-y-1">
{lines.map((line, index) => (
<li key={`${line.name ?? "line"}-${index}`} className="flex items-start gap-2">
<span
aria-hidden="true"
className="mt-1 inline-block h-0.5 w-4 shrink-0 rounded-full"
style={{ background: line.color }}
/>
<span>
<span className="font-medium text-foreground">{line.name || `连线 ${index + 1}`}</span>
{line.arrow !== false ? <span>,带箭头{line.flow === false ? "" : "并沿线流动"}</span> : null}
</span>
</li>
))}
</ul>
) : null}
</details>
) : null}
</VisualizationCard>
);
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import {
curvedLineCoordinates,
flowArrowsForLines,
initialMapCamera,
overlayPadding,
pointAndBearing,
polylineMetrics,
resolveMarkerColor,
shortenProjectedPath,
svgPathFromCoordinates,
} from "./map-style.ts";
test("assigns distinct colors when the model omits them", () => {
assert.notEqual(resolveMarkerColor(0), resolveMarkerColor(1));
assert.equal(resolveMarkerColor(0, "#111111"), "#111111");
});
test("walks a line and wraps flowing arrows", () => {
const metrics = polylineMetrics([
[0, 0],
[10, 0],
]);
assert.ok(metrics);
const start = pointAndBearing(metrics!, 0);
const mid = pointAndBearing(metrics!, 5);
const wrapped = pointAndBearing(metrics!, 12);
assert.deepEqual(start.point, [0, 0]);
assert.equal(start.bearing, 90);
assert.deepEqual(mid.point, [5, 0]);
assert.equal(wrapped.point[0], 2);
assert.equal(wrapped.bearing, 90);
const arrows = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0,
);
assert.equal(arrows.length, 3);
const staticArrow = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0,
{ reducedMotion: true },
);
assert.equal(staticArrow.length, 1);
assert.ok(staticArrow[0]!.point[0] > 5);
});
test("builds an svg path from projected coordinates", () => {
const path = svgPathFromCoordinates(
[
[116.4, 39.9],
[121.47, 31.23],
],
([lng, lat]) => ({ x: lng, y: lat }),
);
assert.equal(path, "M116.4 39.9 L121.5 31.2");
});
test("turns a two-point connection into a bulging arc", () => {
const points = curvedLineCoordinates([
[0, 0],
[10, 0],
]);
assert.ok(points.length > 2);
const mid = points[Math.floor(points.length / 2)]!;
assert.ok(mid[1] > 0.8);
assert.deepEqual(points[0], [0, 0]);
assert.equal(points[points.length - 1]![0], 10);
assert.ok(Math.abs(points[points.length - 1]![1]) < 1e-9);
const arrows = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0.2,
);
assert.ok(arrows[0]!.point[1] > 0);
});
test("shortens a projected path so arrows sit off the markers", () => {
const trimmed = shortenProjectedPath(
[
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
10,
13,
);
assert.equal(trimmed.length, 2);
assert.ok(trimmed[0]!.x >= 9.9);
assert.ok(trimmed[1]!.x <= 87.1);
});
test("frames Beijing and Shanghai even when the model sent a China-wide camera", () => {
const beijing: [number, number] = [116.4074, 39.9042];
const shanghai: [number, number] = [121.4737, 31.2304];
const camera = initialMapCamera([beijing, shanghai], { center: [104.1954, 35.8617], zoom: 4 });
assert.equal(camera.kind, "bounds");
if (camera.kind !== "bounds") return;
assert.ok(camera.sw[0] <= beijing[0] && camera.sw[1] <= shanghai[1]);
assert.ok(camera.ne[0] >= shanghai[0] && camera.ne[1] >= beijing[1]);
assert.equal(camera.maxZoom, 12);
});
test("centers a single marker instead of using the model overview", () => {
const camera = initialMapCamera([[116.4, 39.9]], { center: [104.2, 35.8], zoom: 4 });
assert.deepEqual(camera, { kind: "center", center: [116.4, 39.9], zoom: 8 });
});
test("keeps padding inside a typical map card", () => {
const padding = overlayPadding(800, 420);
assert.ok(padding >= 32 && padding <= 80);
assert.ok(padding * 2 < 420);
});
@@ -0,0 +1,294 @@
export const MAP_MARKER_PALETTE = [
"#2563eb",
"#dc2626",
"#16a34a",
"#d97706",
"#7c3aed",
"#0891b2",
"#db2777",
"#4f46e5",
] as const;
export type MapPoint = [number, number];
export function resolveMarkerColor(index: number, explicit?: string): string {
return explicit ?? MAP_MARKER_PALETTE[index % MAP_MARKER_PALETTE.length]!;
}
export function resolveLineColor(
index: number,
explicit?: string,
fromMarkerColor?: string,
): string {
return explicit ?? fromMarkerColor ?? MAP_MARKER_PALETTE[index % MAP_MARKER_PALETTE.length]!;
}
export function coordinatesMatch(left: MapPoint, right: MapPoint, epsilon = 1e-4): boolean {
return Math.abs(left[0] - right[0]) <= epsilon && Math.abs(left[1] - right[1]) <= epsilon;
}
export type PolylineMetrics = {
coordinates: MapPoint[];
parts: number[];
total: number;
};
export function polylineMetrics(coordinates: MapPoint[]): PolylineMetrics | null {
if (coordinates.length < 2) return null;
const parts: number[] = [];
let total = 0;
for (let index = 1; index < coordinates.length; index += 1) {
const current = coordinates[index]!;
const previous = coordinates[index - 1]!;
const length = Math.hypot(current[0] - previous[0], current[1] - previous[1]);
parts.push(length);
total += length;
}
if (!(total > 0)) return null;
return { coordinates, parts, total };
}
export function pointAndBearing(
metrics: PolylineMetrics,
distance: number,
): { point: MapPoint; bearing: number } {
const wrapped = ((distance % metrics.total) + metrics.total) % metrics.total;
let remaining = wrapped;
for (let index = 0; index < metrics.parts.length; index += 1) {
const part = metrics.parts[index]!;
const start = metrics.coordinates[index]!;
const end = metrics.coordinates[index + 1]!;
if (remaining > part && index < metrics.parts.length - 1) {
remaining -= part;
continue;
}
const t = part === 0 ? 0 : Math.min(1, remaining / part);
const dlng = end[0] - start[0];
const dlat = end[1] - start[1];
return {
point: [start[0] + dlng * t, start[1] + dlat * t],
bearing: (Math.atan2(dlng, dlat) * 180) / Math.PI,
};
}
const last = metrics.coordinates[metrics.coordinates.length - 1]!;
const prev = metrics.coordinates[metrics.coordinates.length - 2]!;
return {
point: last,
bearing: (Math.atan2(last[0] - prev[0], last[1] - prev[1]) * 180) / Math.PI,
};
}
export type FlowLine = {
coordinates: MapPoint[];
color: string;
name?: string;
arrow?: boolean;
flow?: boolean;
};
export type FlowArrow = {
point: MapPoint;
bearing: number;
color: string;
lineName: string;
};
export const LINE_ARC_BULGE = 0.22;
export const LINE_ARC_SAMPLES = 20;
export function quadraticPoint(start: MapPoint, control: MapPoint, end: MapPoint, t: number): MapPoint {
const u = 1 - t;
return [
u * u * start[0] + 2 * u * t * control[0] + t * t * end[0],
u * u * start[1] + 2 * u * t * control[1] + t * t * end[1],
];
}
export function arcControlPoint(start: MapPoint, end: MapPoint, bulge = LINE_ARC_BULGE): MapPoint {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const length = Math.hypot(dx, dy);
if (!(length > 0)) return start;
const offset = length * bulge;
return [(start[0] + end[0]) / 2 - (dy / length) * offset, (start[1] + end[1]) / 2 + (dx / length) * offset];
}
export function curvedLineCoordinates(coordinates: MapPoint[], samples = LINE_ARC_SAMPLES): MapPoint[] {
if (coordinates.length < 2) return coordinates;
const points: MapPoint[] = [];
for (let index = 1; index < coordinates.length; index += 1) {
const start = coordinates[index - 1]!;
const end = coordinates[index]!;
if (coordinatesMatch(start, end)) {
if (points.length === 0) points.push(start);
continue;
}
const control = arcControlPoint(start, end);
const from = index === 1 ? 0 : 1;
for (let step = from; step <= samples; step += 1) {
points.push(quadraticPoint(start, control, end, step / samples));
}
}
return points.length >= 2 ? points : coordinates;
}
export function flowArrowsForLines(
lines: FlowLine[],
progress: number,
options?: { reducedMotion?: boolean },
): FlowArrow[] {
const arrows: FlowArrow[] = [];
const reducedMotion = options?.reducedMotion === true;
for (const line of lines) {
if (line.arrow === false) continue;
const metrics = polylineMetrics(curvedLineCoordinates(line.coordinates));
if (!metrics) continue;
const animate = line.flow !== false && !reducedMotion;
const count = animate ? 3 : 1;
const offset = animate ? progress : 0.72;
for (let index = 0; index < count; index += 1) {
const distance = ((offset + index / count) % 1) * metrics.total;
const placed = pointAndBearing(metrics, distance);
arrows.push({
...placed,
color: line.color,
lineName: line.name ?? "",
});
}
}
return arrows;
}
export function svgPathFromPoints(points: Array<{ x: number; y: number }>): string {
return points
.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(1)} ${point.y.toFixed(1)}`)
.join(" ");
}
export function svgPathFromCoordinates(
coordinates: MapPoint[],
project: (point: MapPoint) => { x: number; y: number },
): string {
return svgPathFromPoints(coordinates.map(project));
}
function projectedPathLength(points: Array<{ x: number; y: number }>): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
const current = points[index]!;
const previous = points[index - 1]!;
total += Math.hypot(current.x - previous.x, current.y - previous.y);
}
return total;
}
function trimProjectedPath(
points: Array<{ x: number; y: number }>,
gap: number,
edge: "start" | "end",
): Array<{ x: number; y: number }> {
if (points.length < 2 || gap <= 0) return points;
let remaining = gap;
if (edge === "start") {
let index = 0;
while (index < points.length - 1 && remaining > 0) {
const current = points[index]!;
const next = points[index + 1]!;
const part = Math.hypot(next.x - current.x, next.y - current.y);
if (part <= remaining) {
remaining -= part;
index += 1;
continue;
}
const t = remaining / part;
const cut = { x: current.x + (next.x - current.x) * t, y: current.y + (next.y - current.y) * t };
return [cut, ...points.slice(index + 1)];
}
return points.slice(-1);
}
let index = points.length - 1;
while (index > 0 && remaining > 0) {
const current = points[index]!;
const previous = points[index - 1]!;
const part = Math.hypot(current.x - previous.x, current.y - previous.y);
if (part <= remaining) {
remaining -= part;
index -= 1;
continue;
}
const t = remaining / part;
const cut = { x: current.x + (previous.x - current.x) * t, y: current.y + (previous.y - current.y) * t };
return [...points.slice(0, index), cut];
}
return points.slice(0, 1);
}
export function shortenProjectedPath(
points: Array<{ x: number; y: number }>,
startGap: number,
endGap: number,
): Array<{ x: number; y: number }> {
if (points.length < 2) return points;
const total = projectedPathLength(points);
if (!(total > 0)) return points;
const maxGap = total / 2 - 4;
if (maxGap <= 0) return points;
return trimProjectedPath(
trimProjectedPath(points, Math.min(startGap, maxGap), "start"),
Math.min(endGap, maxGap),
"end",
);
}
export const FALLBACK_MAP_CENTER: MapPoint = [104.1954, 35.8617];
export const SINGLE_POINT_ZOOM = 8;
export const MULTI_POINT_MAX_ZOOM = 12;
export const FALLBACK_MAP_ZOOM = 4;
export type MapCamera =
| { kind: "center"; center: MapPoint; zoom: number }
| { kind: "bounds"; sw: MapPoint; ne: MapPoint; maxZoom: number };
export function boundsFromCoordinates(coordinates: MapPoint[]): { sw: MapPoint; ne: MapPoint } | null {
if (coordinates.length === 0) return null;
let minLng = coordinates[0]![0];
let minLat = coordinates[0]![1];
let maxLng = minLng;
let maxLat = minLat;
for (const [lng, lat] of coordinates) {
minLng = Math.min(minLng, lng);
minLat = Math.min(minLat, lat);
maxLng = Math.max(maxLng, lng);
maxLat = Math.max(maxLat, lat);
}
return { sw: [minLng, minLat], ne: [maxLng, maxLat] };
}
export function isDegenerateBounds(sw: MapPoint, ne: MapPoint, epsilon = 1e-5): boolean {
return Math.abs(ne[0] - sw[0]) <= epsilon && Math.abs(ne[1] - sw[1]) <= epsilon;
}
export function overlayPadding(width: number, height: number): number {
return Math.max(32, Math.min(80, Math.floor(Math.min(width, height) * 0.14)));
}
export function initialMapCamera(
coordinates: MapPoint[],
hint?: { center?: MapPoint; zoom?: number },
): MapCamera {
if (coordinates.length === 0) {
return {
kind: "center",
center: hint?.center ?? FALLBACK_MAP_CENTER,
zoom: hint?.zoom ?? FALLBACK_MAP_ZOOM,
};
}
if (coordinates.length === 1) {
return { kind: "center", center: coordinates[0]!, zoom: hint?.zoom && hint.zoom >= 6 ? hint.zoom : SINGLE_POINT_ZOOM };
}
const bounds = boundsFromCoordinates(coordinates);
if (!bounds || isDegenerateBounds(bounds.sw, bounds.ne)) {
return { kind: "center", center: coordinates[0]!, zoom: SINGLE_POINT_ZOOM };
}
return { kind: "bounds", sw: bounds.sw, ne: bounds.ne, maxZoom: MULTI_POINT_MAX_ZOOM };
}
@@ -2,7 +2,12 @@ import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
import {
parseMapColor,
parseOptionalBoolean,
parseVisualizationSpec,
splitVisualizationContent,
} from "./visualization-spec.ts";
test("ordinary markdown remains one unchanged segment", () => {
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
@@ -49,6 +54,30 @@ test("accepts a dedicated mindmap contract", () => {
assert.equal(result.ok && result.spec.type, "mindmap");
});
test("accepts four meaningful children under a non-root analysis node", () => {
const result = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
title: "人物分析",
rootId: "root",
nodes: [
{ id: "root", parentId: null, label: "人物分析", kind: "topic" },
{ id: "basic", parentId: "root", label: "基本信息", kind: "dimension" },
{ id: "identity", parentId: "basic", label: "身份", kind: "finding" },
{ id: "experience", parentId: "basic", label: "经历", kind: "finding" },
{ id: "relations", parentId: "basic", label: "关系", kind: "finding" },
{ id: "events", parentId: "basic", label: "事件", kind: "finding" },
],
}),
);
assert.equal(result.ok, true);
assert.equal(
result.ok && result.spec.type === "mindmap" && result.spec.nodes.length,
6,
);
});
test("renders a completed legacy mindmap fence as a mindmap", () => {
const content = [
"```mindmap",
@@ -105,6 +134,26 @@ test("rejects dense mindmaps before they can overload the canvas", () => {
);
assert.equal(tooManyChildren.ok, false);
const tooManyBranchChildren = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "basic", parentId: "root", label: "基本信息" },
...Array.from({ length: 5 }, (_, index) => ({
id: `detail-${index}`,
parentId: "basic",
label: `要点 ${index + 1}`,
})),
],
}),
);
assert.equal(tooManyBranchChildren.ok, false);
assert.equal(
tooManyBranchChildren.ok ? "" : tooManyBranchChildren.error,
"节点“基本信息”最多保留 4 个子节点,当前有 5 个;请合并同类信息",
);
const tooManyNodes = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
@@ -125,6 +174,27 @@ test("rejects dense mindmaps before they can overload the canvas", () => {
);
});
test("keeps the four-layer depth guard after branch tolerance", () => {
const tooDeep = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "level-1", parentId: "root", label: "一级" },
{ id: "level-2", parentId: "level-1", label: "二级" },
{ id: "level-3", parentId: "level-2", label: "三级" },
{ id: "level-4", parentId: "level-3", label: "四级" },
],
}),
);
assert.equal(tooDeep.ok, false);
assert.equal(
tooDeep.ok ? "" : tooDeep.error,
"思维导图最多支持 4 层层级",
);
});
test("renders a valid map contract from a generic json fence", () => {
const content = [
"下面是地图:",
@@ -165,6 +235,139 @@ test("rejects invalid map coordinates", () => {
assert.equal(result.ok, false);
});
test("recovers Beijing-Shanghai lines that omit coordinates", () => {
const fromToByName = parseVisualizationSpec(
"map-json",
JSON.stringify({
title: "北京上海位置地图",
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "北京-上海", from: "北京", to: "上海" }],
}),
);
assert.equal(fromToByName.ok, true);
assert.equal(fromToByName.ok && fromToByName.spec.type, "map");
if (fromToByName.ok && fromToByName.spec.type === "map") {
assert.equal(fromToByName.spec.markers.length, 2);
assert.deepEqual(fromToByName.spec.lines[0]?.coordinates, [
[116.4, 39.9],
[121.47, 31.23],
]);
}
const singularCoordinate = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "联络", coordinate: [[116.4, 39.9], [121.47, 31.23]] }],
}),
);
assert.equal(singularCoordinate.ok, true);
if (singularCoordinate.ok && singularCoordinate.spec.type === "map") {
assert.equal(singularCoordinate.spec.lines.length, 1);
}
const echartsCoords = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "联络", coords: [[116.4, 39.9], [121.47, 31.23]] }],
}),
);
assert.equal(echartsCoords.ok, true);
if (echartsCoords.ok && echartsCoords.spec.type === "map") {
assert.equal(echartsCoords.spec.lines.length, 1);
}
const fromToPoints = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", lng: 116.4, lat: 39.9 },
{ name: "上海", longitude: "121.47", latitude: "31.23" },
],
lines: [{ from: [116.4, 39.9], to: [121.47, 31.23] }],
}),
);
assert.equal(fromToPoints.ok, true);
if (fromToPoints.ok && fromToPoints.spec.type === "map") {
assert.equal(fromToPoints.spec.markers.length, 2);
assert.equal(fromToPoints.spec.lines.length, 1);
}
});
test("keeps markers when a line cannot be recovered", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({
title: "北京上海位置地图",
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "北京-上海" }],
}),
);
assert.equal(result.ok, true);
if (result.ok && result.spec.type === "map") {
assert.equal(result.spec.markers.length, 2);
assert.equal(result.spec.lines.length, 0);
}
});
test("parses named, Chinese and hex marker colors", () => {
assert.equal(parseMapColor("red"), "#dc2626");
assert.equal(parseMapColor("蓝色"), "#2563eb");
assert.equal(parseMapColor("#0f8"), "#00ff88");
assert.equal(parseMapColor("#2563EB"), "#2563eb");
assert.equal(parseMapColor("javascript:alert(1)"), undefined);
assert.equal(parseOptionalBoolean("是"), true);
assert.equal(parseOptionalBoolean("false"), false);
});
test("keeps marker colors and line arrow/flow flags", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9], color: "red" },
{ name: "上海", coordinate: [121.47, 31.23], colour: "#16a34a" },
],
lines: [
{
name: "北京-上海",
from: "北京",
to: "上海",
color: "蓝色",
arrow: "是",
flow: true,
},
],
}),
);
assert.equal(result.ok, true);
if (result.ok && result.spec.type === "map") {
assert.equal(result.spec.markers[0]?.color, "#dc2626");
assert.equal(result.spec.markers[1]?.color, "#16a34a");
assert.equal(result.spec.lines[0]?.color, "#2563eb");
assert.equal(result.spec.lines[0]?.arrow, true);
assert.equal(result.spec.lines[0]?.flow, true);
}
});
test("does not treat a generic json log as a map after dropping invalid lines", () => {
const content = '```json\n{"title":"日志","lines":["error 1","error 2"]}\n```';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("flow and chart fences stay on the ordinary Markdown path", () => {
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
@@ -10,6 +10,10 @@ const optionalText = z.string().trim().max(2_000).optional();
const longitude = z.number().finite().min(-180).max(180);
const latitude = z.number().finite().min(-90).max(90);
const coordinateSchema = z.tuple([longitude, latitude]);
const mapColorSchema = z
.string()
.regex(/^#[0-9a-f]{6}$/i, "颜色必须是 #rrggbb")
.optional();
const mapSchema = z.object({
type: z.literal("map").default("map"),
@@ -22,6 +26,7 @@ const mapSchema = z.object({
name: safeText,
coordinate: coordinateSchema,
description: optionalText,
color: mapColorSchema,
}),
)
.max(500)
@@ -31,6 +36,9 @@ const mapSchema = z.object({
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(2).max(2_000),
color: mapColorSchema,
arrow: z.boolean().default(true),
flow: z.boolean().default(true),
}),
)
.max(100)
@@ -40,6 +48,7 @@ const mapSchema = z.object({
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(3).max(2_000),
color: mapColorSchema,
}),
)
.max(100)
@@ -83,17 +92,312 @@ export type MindmapSpec = z.infer<typeof mindmapSchema>;
type MindmapNode = MindmapSpec["nodes"][number];
export type VisualizationSpec = MapSpec | MindmapSpec;
const MINDMAP_ROOT_CHILD_LIMIT = 6;
// The generation prompt still asks the model to target three children so the
// overview stays compact. Real analysis dimensions can naturally contain four
// equally important facts, though, and rejecting the entire graph for that
// one-node overflow is too brittle. Keep a one-item rendering tolerance while
// the global node, depth and cycle guards continue to bound the canvas.
const MINDMAP_BRANCH_CHILD_LIMIT = 4;
export type VisualizationParseResult =
| { ok: true; spec: VisualizationSpec }
| { ok: false; error: string };
const NAMED_MAP_COLORS: Record<string, string> = {
red: "#dc2626",
orange: "#ea580c",
amber: "#d97706",
yellow: "#ca8a04",
green: "#16a34a",
emerald: "#059669",
teal: "#0d9488",
cyan: "#0891b2",
blue: "#2563eb",
indigo: "#4f46e5",
violet: "#7c3aed",
purple: "#7c3aed",
pink: "#db2777",
rose: "#e11d48",
gray: "#64748b",
grey: "#64748b",
slate: "#64748b",
black: "#0f172a",
white: "#f8fafc",
红: "#dc2626",
红色: "#dc2626",
橙: "#ea580c",
橙色: "#ea580c",
黄: "#ca8a04",
黄色: "#ca8a04",
绿: "#16a34a",
绿色: "#16a34a",
青: "#0891b2",
青色: "#0891b2",
蓝: "#2563eb",
蓝色: "#2563eb",
紫: "#7c3aed",
紫色: "#7c3aed",
粉: "#db2777",
粉色: "#db2777",
灰: "#64748b",
灰色: "#64748b",
};
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
export function parseMapColor(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const raw = value.trim();
if (!raw || raw.length > 24) return undefined;
if (/url\s*\(|javascript:|expression\s*\(/i.test(raw)) return undefined;
const named = NAMED_MAP_COLORS[raw.toLowerCase()] ?? NAMED_MAP_COLORS[raw];
if (named) return named;
if (!HEX_COLOR.test(raw)) return undefined;
const hex = raw.slice(1);
if (hex.length === 3) {
return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`.toLowerCase();
}
return `#${hex.slice(0, 6).toLowerCase()}`;
}
export function parseOptionalBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (value === 1 || value === "1") return true;
if (value === 0 || value === "0") return false;
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (["true", "yes", "on", "是"].includes(normalized)) return true;
if (["false", "no", "off", "否"].includes(normalized)) return false;
return undefined;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function parseCoordinate(value: unknown): [number, number] | null {
if (Array.isArray(value) && value.length >= 2) {
const lng = asFiniteNumber(value[0]);
const lat = asFiniteNumber(value[1]);
if (lng != null && lat != null && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90) {
return [lng, lat];
}
return null;
}
const record = asRecord(value);
if (!record) return null;
const nested =
record.coordinate ?? record.coordinates ?? record.coord ?? record.position ?? record.location;
if (nested !== value && (Array.isArray(nested) || asRecord(nested))) {
const nestedCoordinate = parseCoordinate(nested);
if (nestedCoordinate) return nestedCoordinate;
}
const lng = asFiniteNumber(record.lng ?? record.lon ?? record.longitude ?? record.x);
const lat = asFiniteNumber(record.lat ?? record.latitude ?? record.y);
if (lng != null && lat != null && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90) {
return [lng, lat];
}
return null;
}
function parseCoordinateList(value: unknown): Array<[number, number]> {
if (!Array.isArray(value) || value.length === 0) return [];
const direct = value
.map(parseCoordinate)
.filter((item): item is [number, number] => item != null);
if (direct.length >= 2) return direct;
if (value.length === 1 && Array.isArray(value[0])) return parseCoordinateList(value[0]);
return direct;
}
function featureName(record: Record<string, unknown>): string | undefined {
const value = record.name ?? record.label ?? record.title ?? record.city ?? record.text;
if (typeof value !== "string") return undefined;
const name = value.trim();
return name.length > 0 ? name : undefined;
}
function featureColor(record: Record<string, unknown>): string | undefined {
return parseMapColor(
record.color ?? record.colour ?? record.fill ?? record.fillColor ?? record.stroke,
);
}
function withOptionalStyle(
record: Record<string, unknown>,
extra?: Record<string, unknown>,
): Record<string, unknown> {
const color = featureColor(record);
return {
...extra,
...(color ? { color } : {}),
};
}
function normalizeMarkers(value: unknown): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const markers: Array<Record<string, unknown>> = [];
for (const item of items) {
const record = asRecord(item);
if (!record) continue;
const name = featureName(record);
const coordinate = parseCoordinate(item);
if (!name || !coordinate) continue;
markers.push({
name,
coordinate,
...withOptionalStyle(record, {
...(typeof record.description === "string" ? { description: record.description } : {}),
}),
});
}
return markers;
}
function resolvePoint(
value: unknown,
markersByName: Map<string, [number, number]>,
): [number, number] | null {
const coordinate = parseCoordinate(value);
if (coordinate) return coordinate;
if (typeof value === "string") return markersByName.get(value.trim()) ?? null;
const record = asRecord(value);
if (!record) return null;
const name = featureName(record);
return name ? (markersByName.get(name) ?? null) : null;
}
function normalizeLines(
value: unknown,
markersByName: Map<string, [number, number]>,
): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const lines: Array<Record<string, unknown>> = [];
for (const item of items) {
if (Array.isArray(item)) {
const coordinates = parseCoordinateList(item);
if (coordinates.length >= 2) lines.push({ coordinates });
continue;
}
const record = asRecord(item);
if (!record) continue;
const listed = parseCoordinateList(
record.coordinates ??
record.coordinate ??
record.coords ??
record.path ??
record.points ??
record.positions,
);
const from = resolvePoint(record.from ?? record.start ?? record.source, markersByName);
const to = resolvePoint(record.to ?? record.end ?? record.target, markersByName);
const coordinates = listed.length >= 2 ? listed : from && to ? [from, to] : [];
if (coordinates.length < 2) continue;
const name = featureName(record);
const arrow = parseOptionalBoolean(record.arrow ?? record.arrows ?? record.directed);
const flow = parseOptionalBoolean(record.flow ?? record.animate ?? record.animated);
lines.push({
...(name ? { name } : {}),
coordinates,
...withOptionalStyle(record),
...(arrow != null ? { arrow } : {}),
...(flow != null ? { flow } : {}),
});
}
return lines;
}
function normalizePolygons(value: unknown): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const polygons: Array<Record<string, unknown>> = [];
for (const item of items) {
if (Array.isArray(item)) {
const coordinates = parseCoordinateList(item);
if (coordinates.length >= 3) polygons.push({ coordinates });
continue;
}
const record = asRecord(item);
if (!record) continue;
const coordinates = parseCoordinateList(
record.coordinates ??
record.coordinate ??
record.coords ??
record.path ??
record.points ??
record.positions,
);
if (coordinates.length < 3) continue;
const name = featureName(record);
polygons.push({
...(name ? { name } : {}),
coordinates,
...withOptionalStyle(record),
});
}
return polygons;
}
function normalizeMapPayload(value: Record<string, unknown>): Record<string, unknown> {
const markers = Object.prototype.hasOwnProperty.call(value, "markers")
? normalizeMarkers(value.markers)
: undefined;
const markersByName = new Map<string, [number, number]>();
for (const marker of markers ?? []) {
const name = typeof marker.name === "string" ? marker.name : "";
const coordinate = marker.coordinate;
if (name && Array.isArray(coordinate) && coordinate.length === 2) {
markersByName.set(name, coordinate as [number, number]);
}
}
const lines = Object.prototype.hasOwnProperty.call(value, "lines")
? normalizeLines(value.lines, markersByName)
: undefined;
const polygons = Object.prototype.hasOwnProperty.call(value, "polygons")
? normalizePolygons(value.polygons)
: undefined;
const center = parseCoordinate(value.center) ?? undefined;
const zoom = asFiniteNumber(value.zoom) ?? undefined;
return {
...value,
type: "map",
...(center ? { center } : {}),
...(zoom != null ? { zoom } : {}),
...(markers ? { markers } : {}),
...(lines ? { lines } : {}),
...(polygons ? { polygons } : {}),
};
}
function mapHasRenderable(record: Record<string, unknown>): boolean {
return (
(Array.isArray(record.markers) && record.markers.length > 0) ||
(Array.isArray(record.lines) && record.lines.length > 0) ||
(Array.isArray(record.polygons) && record.polygons.length > 0) ||
parseCoordinate(record.center) != null
);
}
function normalizeLanguageType(
language: VisualizationLanguage,
value: unknown,
): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = { ...(value as Record<string, unknown>) };
if (language === "map-json") record.type = "map";
if (language === "map-json") {
const normalized = normalizeMapPayload(record);
return mapHasRenderable(normalized) ? normalized : { ...record, type: "map" };
}
if (language === "mindmap-json") record.type = "mindmap";
return record;
}
@@ -131,12 +435,15 @@ function validateMindmap(spec: MindmapSpec): string | null {
children.set(node.parentId, siblings);
}
for (const [parentId, siblings] of children) {
const limit = parentId === root.id ? 6 : 3;
const limit =
parentId === root.id
? MINDMAP_ROOT_CHILD_LIMIT
: MINDMAP_BRANCH_CHILD_LIMIT;
if (siblings.length > limit) {
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
return parentId === root.id
? `中心主题最多保留 6 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
: `节点“${parentLabel}”最多保留 3 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
? `中心主题最多保留 ${MINDMAP_ROOT_CHILD_LIMIT} 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
: `节点“${parentLabel}”最多保留 ${MINDMAP_BRANCH_CHILD_LIMIT} 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
}
}
@@ -210,18 +517,14 @@ export type VisualizationSegment =
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
function isMapJsonSource(source: string): boolean {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return false;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const record = raw as Record<string, unknown>;
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
(field) => Object.prototype.hasOwnProperty.call(record, field),
const parsed = parseVisualizationSpec("map-json", source);
if (!parsed.ok || parsed.spec.type !== "map") return false;
return (
parsed.spec.markers.length > 0 ||
parsed.spec.lines.length > 0 ||
parsed.spec.polygons.length > 0 ||
parsed.spec.center != null
);
return hasMapField && parseVisualizationSpec("map-json", source).ok;
}
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
@@ -6,6 +6,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -50,12 +51,13 @@ export function SidebarCollapseButton({ className }: { className?: string }) {
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const systemName = useAppSystemName(t.pages.appName);
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
{systemName}
</div>
</div>
);
@@ -13,7 +13,6 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Landmark, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
@@ -147,15 +146,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/scheduled/tasks",
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
},
{
id: "workspace-knowledge",
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) =>
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
// {
// id: "workspace-knowledge",
// label: "知识库管理",
// icon: BookOpen,
// path: "/page/workspace/knowledge",
// match: (pathname) =>
// pathname.startsWith("/page/workspace/knowledge") &&
// !pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
// },
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
@@ -188,7 +187,6 @@ export function WorkspaceNavChatList({
showCollapseTrigger?: boolean;
compact?: boolean;
}) {
const { t } = useI18n();
const pathname = usePathname();
const navigate = useNavigate();
const { sidebarItems } = useWorkspaceCompactNavItems();
@@ -219,38 +217,42 @@ export function WorkspaceNavChatList({
{compact ? (
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{newChatItem && (
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{newChatItem.label}</span>
</Link>
</SidebarMenuButton>
)}
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
) : (
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
{newChatItem && (
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<MessageSquarePlus />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
>
<MessageSquarePlus />
<span>{newChatItem.label}</span>
</Link>
</SidebarMenuButton>
)}
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
@@ -18,12 +18,13 @@ import {
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
ShieldAlertIcon,
SparklesIcon,
TagsIcon,
TimerIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import {
@@ -93,11 +94,34 @@ export function WorkspaceNavMenu() {
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const { settingsItems } = useWorkspaceCompactNavItems();
// 整页缩放时菜单会盖住按钮。按下后松手若落在菜单项上,Radix 会当成选中并立刻关闭。
// 这次打开手势的 pointerup / click 不传给菜单项。
const openingGesture = useRef(false);
useEffect(() => {
setMounted(true);
}, []);
const markOpeningGesture = () => {
openingGesture.current = true;
window.setTimeout(() => {
openingGesture.current = false;
}, 400);
};
const swallowOpeningPointerUp = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
if (!openingGesture.current) return;
event.preventDefault();
event.stopPropagation();
};
const swallowOpeningClick = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
if (!openingGesture.current) return;
openingGesture.current = false;
event.preventDefault();
event.stopPropagation();
};
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
const startCompactGuide = () => {
@@ -141,19 +165,23 @@ export function WorkspaceNavMenu() {
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
onPointerDown={markOpeningGesture}
>
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="top"
align="end"
sideOffset={4}
sideOffset={8}
onPointerUpCapture={swallowOpeningPointerUp}
onClickCapture={swallowOpeningClick}
>
<DropdownMenuGroup>
{settingsItems.map((item) => {
@@ -274,6 +302,14 @@ export function WorkspaceNavMenu() {
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollTextIcon />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
@@ -282,6 +318,14 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/stream-truncations")}
>
<ShieldAlertIcon />
模型截断记录
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
@@ -35,11 +35,10 @@ const NAV_ITEM_CLASS =
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
export function WorkspaceNavStudio() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { pathname } = useLocation();
const queryClient = useQueryClient();
const [open, setOpen] = useState(true);
const [open, setOpen] = useState(false);
const { notebooks, isLoading, isError, token } = useStudioNotebooks(open);
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
@@ -65,11 +64,6 @@ export function WorkspaceNavStudio() {
}
};
// 加载中、token 失败、空列表都不渲染整段,避免在 token 未确认前先闪一段工作室 UI 再消失。
if (isLoading || isError || notebooks.length === 0) {
return null;
}
const SIDEBAR_PREVIEW_COUNT = 2;
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
@@ -96,12 +90,29 @@ export function WorkspaceNavStudio() {
<SidebarMenuButton
className={cn(NAV_ITEM_CLASS)}
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
disabled={!token}
>
<Plus className="shrink-0" />
<span>新建空间</span>
</SidebarMenuButton>
</SidebarMenuItem>
{isLoading && (
<SidebarMenuItem>
<div className="px-3 py-2 text-xs text-sidebar-foreground/60">正在连接工作室…</div>
</SidebarMenuItem>
)}
{isError && (
<SidebarMenuItem>
<div className="px-3 py-2 text-xs text-sidebar-foreground/60">工作室暂不可用</div>
</SidebarMenuItem>
)}
{!isLoading && !isError && notebooks.length === 0 && (
<SidebarMenuItem>
<div className="px-3 py-2 text-xs text-sidebar-foreground/60">暂无工作室空间</div>
</SidebarMenuItem>
)}
{/* 空间列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
{preview.map((nb) => (
<SidebarMenuItem key={nb.id}>
-36
View File
@@ -133,20 +133,6 @@ export interface AdminLeaderboardResponse {
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminUserAnalyticsResponse {
range_days: number;
since: string;
until: string;
user: AdminLeaderboardUser;
overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminLeaderboardReportResponse {
report: string;
generated_at: string;
@@ -294,28 +280,6 @@ export async function refreshTodayLeaderboard(
return result;
}
export async function getUserAnalytics(
userId: string,
range: { since: string; until: string; days?: number },
limit = 20,
): Promise<AdminUserAnalyticsResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
const url = `${base()}/${encodeURIComponent(userId)}/analytics?${params}`;
const result = await parse<AdminUserAnalyticsResponse>(await apiFetch(url), "Failed to load user analytics");
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-管理后台-查看用户分析",
url,
reqParam: { userId, ...range, limit },
});
return result;
}
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type AuditRetentionSource =
| "llm_metrics"
| "tool_metrics"
| "run_summaries"
| "leaderboard_snapshots";
export interface AuditRetentionSweep {
started_at: string;
finished_at: string;
deleted: Partial<Record<AuditRetentionSource, number>>;
stopped_by_admin: boolean;
}
export interface AuditRetentionStatus {
status: "scheduled" | "running" | "paused" | "error" | string;
display_status: "scheduled" | "running" | "paused" | "pausing" | "error" | string;
paused: boolean;
pause_requested: boolean;
current_source: AuditRetentionSource | null;
round: number;
max_rounds: number;
deleted: Partial<Record<AuditRetentionSource, number>>;
retention_days: number;
interval_seconds: number;
startup_delay_seconds: number;
batch_size: number;
max_sweep_seconds: number;
started_at: string | null;
finished_at?: string | null;
next_run_at: number | null;
last_sweep: AuditRetentionSweep | null;
last_error?: string | null;
}
const QUERY_KEY = ["admin-audit-retention"] as const;
const base = () => `${getBackendBaseURL()}/api/admin/audit-retention`;
async function parse<T>(response: Response, action: string): Promise<T> {
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as { detail?: string };
throw new Error(error.detail ?? `${action}失败 (HTTP ${response.status})`);
}
return response.json() as Promise<T>;
}
export async function getAuditRetentionStatus(): Promise<AuditRetentionStatus> {
return parse(await apiFetch(base()), "读取清理状态");
}
export async function setAuditRetentionPaused(paused: boolean): Promise<AuditRetentionStatus> {
return parse(
await apiFetch(`${base()}/control`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paused }),
}),
paused ? "暂停清理" : "恢复清理",
);
}
export function useAuditRetentionStatus() {
return useQuery({
queryKey: QUERY_KEY,
queryFn: getAuditRetentionStatus,
refetchInterval: (query) => (query.state.data?.paused ? false : 2000),
refetchOnWindowFocus: false,
});
}
export function useSetAuditRetentionPaused() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: setAuditRetentionPaused,
onSuccess: (status) => queryClient.setQueryData(QUERY_KEY, status),
});
}
-13
View File
@@ -7,7 +7,6 @@ import {
deleteAdminV1Fact,
generateUserLeaderboardReport,
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
refreshTodayLeaderboard,
listUserQa,
@@ -162,18 +161,6 @@ export function useRefreshTodayLeaderboard() {
});
}
export function useUserAnalytics(
userId: string | null,
range: { since: string; until: string; days?: number },
limit = 20,
) {
return useQuery({
queryKey: ["admin-activity", "analytics", userId, range.since, range.until, limit],
queryFn: () => getUserAnalytics(userId!, range, limit),
enabled: !!userId,
});
}
export function useGenerateUserLeaderboardReport() {
return useMutation({
mutationFn: ({
@@ -0,0 +1,28 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface ModelExchangeRecord {
id: string;
at: string;
model: string;
user_id: string;
enable_thinking: string;
request_preview: string;
response_preview: string;
request_text: string;
request_body: string;
response_text: string;
response_raw: string;
response_content: string;
response_reasoning: string;
reasoning_field: string;
}
export async function loadModelExchanges(): Promise<ModelExchangeRecord[]> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/model-exchanges`);
if (!response.ok) {
throw new Error("模型收发记录读取失败");
}
const body = (await response.json()) as { records?: ModelExchangeRecord[] };
return body.records ?? [];
}
@@ -0,0 +1,53 @@
/**
* Shared client-side bounds for administrator history queries. The server is
* authoritative, but keeping the controls inside this window avoids sending
* expensive requests that will be clipped anyway.
*/
export const ADMIN_QUERY_WINDOW_DAYS = 30;
function atStartOfLocalDay(date: Date): Date {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
}
function atEndOfLocalDay(date: Date): Date {
const result = new Date(date);
result.setHours(23, 59, 0, 0);
return result;
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
/** Format a local date in the value format accepted by `<input type="date">`. */
export function formatLocalDateInput(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** Format a local datetime in the value format accepted by datetime-local. */
export function formatLocalDateTimeInput(date: Date): string {
return `${formatLocalDateInput(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function getAdminQueryDateBounds(now = new Date()) {
const latest = atStartOfLocalDay(now);
const earliest = new Date(latest);
earliest.setDate(earliest.getDate() - (ADMIN_QUERY_WINDOW_DAYS - 1));
return {
earliest,
latest,
min: formatLocalDateInput(earliest),
max: formatLocalDateInput(latest),
};
}
/** Full-day defaults for metrics filters, covering the latest 30 calendar days. */
export function getAdminQueryDateTimeBounds(now = new Date()) {
const { earliest, latest } = getAdminQueryDateBounds(now);
return {
min: formatLocalDateTimeInput(atStartOfLocalDay(earliest)),
max: formatLocalDateTimeInput(atEndOfLocalDay(latest)),
};
}
+104 -7
View File
@@ -5,6 +5,7 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import type {
Agent,
AgentSelectorResponse,
AgentSidebarItem,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
@@ -100,20 +101,55 @@ export async function listAgentsPaginated(
return result;
}
export async function listAllAgentsAdmin(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
export interface ListAllAgentsAdminParams {
search?: string;
/** 1-based page index. */
page: number;
pageSize: number;
sort?: AgentSort;
/** Admin console filter: keep only hidden (true) / only visible (false)
* agents. Omit for both. */
hidden?: boolean;
}
/** Admin-only listing with server-side pagination + search — every agent
* (built-in + every user's agents + private-square entries). Caller must be
* ``system_role === "admin"`` or the backend will 403. */
export async function listAllAgentsAdminPaginated(
params: ListAllAgentsAdminParams,
): Promise<PaginatedAgents> {
const qs = new URLSearchParams({ admin: "true" });
if (params.search) qs.set("search", params.search);
qs.set("page", String(params.page));
qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort);
if (params.hidden != null) qs.set("hidden", String(params.hidden));
const url = `/api/agents?${qs.toString()}`;
const res = await apiFetch(`${getBackendBaseURL()}${url}`);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to load agents: ${res.statusText}`);
}
const data = (await res.json()) as { agents: Agent[] };
const data = (await res.json()) as {
agents: Agent[];
total?: number;
page?: number;
page_size?: number;
};
const result: PaginatedAgents = {
agents: data.agents,
total: data.total ?? data.agents.length,
page: data.page ?? params.page,
pageSize: data.page_size ?? params.pageSize,
};
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-智能体列表",
url: `/api/agents?admin=true`,
returnParam: data.agents,
url,
reqParam: { ...params },
returnParam: result,
});
return data.agents;
return result;
}
export async function getAgent(id: string): Promise<Agent> {
@@ -201,6 +237,14 @@ export async function getAgentSelector(): Promise<AgentSelectorResponse> {
return selector;
}
/** Lightweight top-three names for the workspace sidebar. */
export async function listSidebarAgents(): Promise<AgentSidebarItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/sidebar`);
if (!res.ok) throw new Error(`Failed to load sidebar agents: ${res.statusText}`);
const data = (await res.json()) as { agents?: AgentSidebarItem[] };
return Array.isArray(data.agents) ? data.agents : [];
}
export async function favoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "POST",
@@ -401,8 +445,39 @@ export async function setAgentPrivate(
return updated;
}
export async function setAgentFeatured(
/** Admin-only: hide an agent from every non-admin listing (gallery / 我的 /
* 广场 / selector / private square) or restore it. The agent itself stays
* fully usable — only catalog listings drop it. */
export async function setAgentHidden(
id: string,
hidden: boolean,
): Promise<Agent> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/${id}/hidden`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden }),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to set agent hidden: ${res.statusText}`,
);
}
const updated = (await res.json()) as Agent;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-智能体-设置隐藏",
url: `/api/agents/${id}/hidden`,
reqParam: { id, hidden },
returnParam: updated,
});
return updated;
}
export async function setAgentFeatured( id: string,
order: number | null,
): Promise<Agent> {
const res = await apiFetch(
@@ -443,6 +518,28 @@ export interface AgentSkillsSyncResult {
removals: AgentSkillsSyncRemoval[];
}
export interface AgentDirectorySyncResult {
scanned: number;
}
export async function syncAgentDirectory(): Promise<AgentDirectorySyncResult> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/sync-directory`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to rescan agent directory: ${res.statusText}`);
}
const result = (await res.json()) as AgentDirectorySyncResult;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-智能体-扫描目录",
url: `/api/agents/sync-directory`,
returnParam: result,
});
return result;
}
export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/admin/sync-skills`,
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canonicalAgentChatLink,
isAllowedAgentChatUrl,
} from "./chat-link.ts";
test("allows http(s) and in-app paths only", () => {
assert.equal(isAllowedAgentChatUrl("https://example.test/desk"), true);
assert.equal(isAllowedAgentChatUrl("/page/workspace/agents"), true);
assert.equal(isAllowedAgentChatUrl("#/page/workspace/agents"), true);
assert.equal(isAllowedAgentChatUrl("javascript:alert(1)"), false);
assert.equal(isAllowedAgentChatUrl("//example.test"), false);
assert.equal(isAllowedAgentChatUrl(""), false);
});
test("canonical link drops a blank url and blank param keys", () => {
assert.equal(canonicalAgentChatLink({ url: " ", open_mode: "self", route_params: [] }), null);
const link = canonicalAgentChatLink({
url: " https://example.test/desk ",
open_mode: "self",
route_params: [
{ key: " ", paramType: "login", source: "userName" },
{ key: "userName", paramType: "login", source: "userName" },
],
});
assert.deepEqual(link, {
url: "https://example.test/desk",
open_mode: "self",
route_params: [
{
key: "userName",
paramType: "login",
source: "userName",
customValue: null,
themeValue: null,
},
],
});
});
+113
View File
@@ -0,0 +1,113 @@
const URL_MAX = 2048;
export interface AgentChatLinkDraft {
url?: string | null;
open_mode?: "self" | "blank" | null;
route_params?: Array<{
key?: string | null;
paramType?: string | null;
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}> | null;
}
export interface CanonicalAgentChatLink {
url: string;
open_mode: "self" | "blank";
route_params: Array<{
key: string;
paramType: "login" | "theme";
source: string | null;
customValue: string | null;
themeValue: string | null;
}>;
}
export function isAllowedAgentChatUrl(url: string): boolean {
const value = url.trim();
if (!value || value.length > URL_MAX) return false;
const lowered = value.toLowerCase();
if (
lowered.startsWith("javascript:") ||
lowered.startsWith("data:") ||
lowered.startsWith("vbscript:") ||
lowered.startsWith("file:")
) {
return false;
}
if (value.startsWith("#/")) return true;
if (value.startsWith("/") && !value.startsWith("//")) return true;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
/** Drop blank rows and normalize the payload sent to the agent API. Empty url clears the link. */
export function canonicalAgentChatLink(
link: AgentChatLinkDraft | null | undefined,
): CanonicalAgentChatLink | null {
const url = (link?.url ?? "").trim();
if (!url) return null;
const routeParams: CanonicalAgentChatLink["route_params"] = [];
for (const raw of link?.route_params ?? []) {
const key = (raw?.key ?? "").trim();
if (!key) continue;
const paramType = raw?.paramType === "theme" ? "theme" : "login";
routeParams.push({
key,
paramType,
source: paramType === "login" ? (raw?.source || "access_token") : null,
customValue:
paramType === "login" && raw?.source === "other" ? (raw.customValue ?? "") : null,
themeValue: paramType === "theme" ? (raw?.themeValue ?? "") : null,
});
}
return {
url,
open_mode: link?.open_mode === "self" ? "self" : "blank",
route_params: routeParams,
};
}
export function agentChatLinksEqual(
left: AgentChatLinkDraft | null | undefined,
right: AgentChatLinkDraft | null | undefined,
): boolean {
return JSON.stringify(canonicalAgentChatLink(left)) === JSON.stringify(canonicalAgentChatLink(right));
}
/**
* Open a fully resolved chat-link URL.
* Paths starting with `/` or `#/` stay inside this HashRouter app.
*/
export function openResolvedAgentChatUrl(
url: string,
openMode: "self" | "blank",
navigate: (path: string) => void,
): void {
const mode = openMode === "self" ? "self" : "blank";
const internal = url.startsWith("#/")
? url.slice(1)
: url.startsWith("/") && !url.startsWith("//")
? url
: null;
if (internal) {
if (mode === "self") {
navigate(internal);
return;
}
const base = window.location.href.split("#")[0];
const hashPath = internal.startsWith("/") ? internal : `/${internal}`;
window.open(`${base}#${hashPath}`, "_blank", "noopener,noreferrer");
return;
}
if (mode === "self") {
window.location.assign(url);
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
+104 -43
View File
@@ -13,26 +13,29 @@ import {
getAgent,
getAgentSelector,
listAgents,
listSidebarAgents,
listAgentsPaginated,
listAllAgentsAdmin,
listAllAgentsAdminPaginated,
listPrivateAgents,
listPrivateAgentsPaginated,
pinAgent,
reorderFeaturedAgents,
setAgentFeatured,
setAgentHidden,
setAgentPrivate,
syncAgentDirectory,
unfavoriteAgent,
unpinAgent,
updateAgent,
} from "./api";
import type {
import type {
Agent,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
import { mergeConversationAgentOptions } from "./conversation-options";
} from "./types";
import { mergeConversationAgentOptions } from "./conversation-options";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
@@ -66,12 +69,16 @@ function applyAgentToCache(
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
}
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
export function useAgents(
params: { search?: string; tagIds?: string[]; enabled?: boolean } = {},
) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const { data, isLoading, error } = useQuery({
queryKey: ["agents", search, [...tagIds].sort()],
queryFn: () => listAgents({ search, tagIds }),
enabled: params.enabled ?? true,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
@@ -137,29 +144,47 @@ export function useAgentsPaginated(params: {
}
/**
* Admin-only listing — returns every agent including unpublished agents owned
* by other users and private-square entries. Caller must be ``system_role
* === "admin"`` or the backend will 403.
* Admin-only paginated listing — every agent including unpublished agents
* owned by other users and private-square entries, sliced server-side.
* Caller must be ``system_role === "admin"`` or the backend will 403.
* Sort defaults to ``time_asc`` so the console keeps its historical
* oldest-first order.
*/
export function useAllAgentsAdmin(enabled: boolean) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "admin", "all"],
queryFn: () => listAllAgentsAdmin(),
enabled,
staleTime: 30_000,
export function useAllAgentsAdminPaginated(params: {
search?: string;
sort?: AgentSort;
page: number;
pageSize: number;
/** Catalog filter: keep only hidden (true) / only visible (false). Omit for both. */
hidden?: boolean;
}) {
const { page, pageSize, hidden } = params;
const search = params.search ?? "";
const sort = params.sort ?? "time_asc";
const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["agents", "admin", "paginated", search, sort, page, pageSize, hidden ?? "any"],
queryFn: () => listAllAgentsAdminPaginated({ search, sort, page, pageSize, hidden }),
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
};
}
export function useAgent(
id: string | null | undefined,
{ enabled = true }: { enabled?: boolean } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
});
export function useAgent(
id: string | null | undefined,
{ enabled = true, retry }: { enabled?: boolean; retry?: boolean | number } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
retry,
});
return { agent: data ?? null, isLoading, error };
}
@@ -243,10 +268,11 @@ export function useToggleAgentPin() {
});
}
export function useAgentSelector() {
export function useAgentSelector({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
enabled,
staleTime: 60_000,
});
return {
@@ -254,24 +280,34 @@ export function useAgentSelector() {
isLoading,
error,
};
}
/**
* Agent catalogue for project/ordinary conversations.
*
* It combines the admin-curated homepage list with every custom agent the
* current user can access. This deliberately does not expose unfeatured
* built-in workflow workers as standalone chat agents.
*/
export function useConversationAgentOptions() {
const featured = useAgentSelector();
const visible = useAgents();
return {
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
isLoading: featured.isLoading || visible.isLoading,
error: featured.error ?? visible.error,
};
}
}
export function useSidebarAgents({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "sidebar"],
queryFn: listSidebarAgents,
enabled,
staleTime: 60_000,
});
return { agents: data ?? [], isLoading, error };
}
/**
* Agent catalogue for project/ordinary conversations.
*
* It combines the admin-curated homepage list with every custom agent the
* current user can access. This deliberately does not expose unfeatured
* built-in workflow workers as standalone chat agents.
*/
export function useConversationAgentOptions() {
const featured = useAgentSelector();
const visible = useAgents();
return {
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
isLoading: featured.isLoading || visible.isLoading,
error: featured.error ?? visible.error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
@@ -388,6 +424,31 @@ export function useSetAgentPrivate() {
});
}
/** Admin-only: hide / restore an agent in the catalog. Hidden agents vanish
* from every non-admin listing, so all agent queries are invalidated. */
export function useSetAgentHidden() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
setAgentHidden(id, hidden),
onSuccess: (data) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
},
});
}
/** Admin gallery action: rescan `.deer-flow/agents/` into the catalog. */
export function useSyncAgentDirectory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: syncAgentDirectory,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useAdminSyncAgentSkills() {
const queryClient = useQueryClient();
return useMutation({
Loaded 100 of 995 files, more files were not shown because too many files have changed in this diff. Show more