diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 56391dbe..1157b8f1 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
-# Pre-commit gate: make all (format BE+FE + lint + typecheck + test) + dashboard build.
+# Pre-commit gate: make precommit (format BE+FE + lint + typecheck + testmon-scoped
+# tests) + dashboard build. The full suite still runs in CI (`make all`); this is a
+# fast, change-aware local gate so commits don't wait on the whole test matrix.
# Enable once per clone: make install-hooks
# Bypass (emergency only): SKIP_PRECOMMIT=1 git commit ...
# Or: git commit --no-verify
@@ -20,8 +22,8 @@ while IFS= read -r line; do
[[ -n "$line" ]] && STAGED_FILES+=("$line")
done < <(git diff --cached --name-only --diff-filter=ACMR || true)
-echo "[pre-commit] make all (format-all + lint + typecheck + test)"
-make all
+echo "[pre-commit] make precommit (format-all + lint + typecheck + testmon-scoped test)"
+make precommit
# If format rewrote files that were already staged, refresh the index so the
# commit includes the formatted content (worktree vs index drift).
diff --git a/.github/workflows/anti-spam-issues.yml b/.github/workflows/anti-spam-issues.yml
new file mode 100644
index 00000000..48155a2b
--- /dev/null
+++ b/.github/workflows/anti-spam-issues.yml
@@ -0,0 +1,303 @@
+name: Anti-spam issue guard
+
+"on":
+ issues:
+ types: [opened]
+ workflow_dispatch:
+ inputs:
+ issue_number:
+ description: "Issue number to check manually, for example 19 or #19"
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ issues: write
+
+jobs:
+ anti-spam:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Close suspicious issues
+ uses: actions/github-script@v7
+ env:
+ LABEL_NAME: "pending-maintainer-review"
+ LABEL_COLOR: "cfd3d7"
+ AUTO_CLOSE: "true"
+ NEW_ACCOUNT_DAYS: "180"
+
+ with:
+ script: |
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+
+ let issue = context.payload.issue;
+
+ if (context.eventName === "workflow_dispatch") {
+ const rawIssueNumber = (
+ core.getInput("issue_number") ||
+ context.payload.inputs?.issue_number ||
+ ""
+ ).trim();
+
+ core.info(`Manual issue_number raw input: "${rawIssueNumber}"`);
+ core.info(`Workflow inputs payload: ${JSON.stringify(context.payload.inputs || {})}`);
+
+ const issueNumber = Number(rawIssueNumber.replace(/^#/, ""));
+
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
+ core.setFailed(`Invalid issue_number: "${rawIssueNumber}"`);
+ return;
+ }
+
+ const issueResponse = await github.rest.issues.get({
+ owner,
+ repo,
+ issue_number: issueNumber
+ });
+
+ issue = issueResponse.data;
+ }
+
+ if (!issue || issue.pull_request) {
+ core.info("Not a normal issue, skipping.");
+ return;
+ }
+
+ const author = issue.user.login;
+ const association = issue.author_association || "NONE";
+
+ const trustedAssociations = new Set([
+ "OWNER",
+ "MEMBER",
+ "COLLABORATOR",
+ "CONTRIBUTOR"
+ ]);
+
+ if (trustedAssociations.has(association)) {
+ core.info(`Trusted author association: ${association}. Skipping.`);
+ return;
+ }
+
+ if (issue.user.type === "Bot") {
+ core.info("Issue author is a bot. Skipping.");
+ return;
+ }
+
+ const userInfo = await github.rest.users.getByUsername({
+ username: author
+ });
+
+ const createdAt = new Date(userInfo.data.created_at);
+ const accountAgeDays = Math.floor((Date.now() - createdAt.getTime()) / 86400000);
+ const newAccountDays = Number(process.env.NEW_ACCOUNT_DAYS || 180);
+ const isNewAccount = accountAgeDays <= newAccountDays;
+
+ const issueText = `${issue.title || ""}\n${issue.body || ""}`;
+ const normalizedIssueText = issueText.replace(/\s+/g, " ");
+
+ function hit(patterns) {
+ return patterns.some(pattern => pattern.test(normalizedIssueText));
+ }
+
+ const strongStarFarmPatterns = [
+ /刷\s*星|买\s*星|假\s*星|增\s*星|刷\s*star|假\s*star/i,
+ /star\s*造假|star\s*作弊|star\s*作假|star\s*异常/i,
+ /star\s*farming|artificial\s*stars?|fake\s*stars?|bot\s*stars?/i,
+ /star\s*boost|star\s*boosting|boosting\s*service/i,
+ /bought\s*stars?|purchased\s*stars?|paid\s*stars?/i
+ ];
+
+ const starContextPatterns = [
+ /\bstars?\b|starred|stargazers?/i,
+ /Star\s*数|Star\s*数量|Star\s*数据|Star\s*列表/i,
+ /星标|颗星|个星|这么多\s*Star/i,
+ /人气|热度/
+ ];
+
+ const abnormalGrowthPatterns = [
+ /异常增长|突然增长|爆发式增长|快速增长|增长异常/i,
+ /暴增|飙升|猛增|激增|疯涨|刷到|涨到/i,
+ /过去\s*48\s*小时|48\s*小时|仅用了?\s*\d+\s*天|两天|2\s*天/i,
+ /新增了?\s*\d+|增加了?\s*\d+|从\s*\d+\s*飙升到\s*\d+/i,
+ /\d+\s*(颗|个)?\s*(Star|stars?|星)/i,
+ /创建时间集中|创建时间高度集中|注册时间高度集中/i
+ ];
+
+ const suspiciousAccountPatterns = [
+ /注册日期集中|注册时间集中|创建日期集中|创建时间集中/i,
+ /集中在最近|最近\s*(两|2)\s*周|最近\s*\d+\s*天|7\s*天内|一周内/i,
+ /用户名像随机|随机字符串|随机生成/i,
+ /个人主页空空如也|主页空空如也|主页空|个人主页空/i,
+ /没有头像|无头像|没有仓库|无公开仓库|没有公开仓库/i,
+ /没有\s*followers?|没有\s*follower|无粉丝|零粉丝|no\s*followers?/i,
+ /空壳号|僵尸号|机器人账号|bot\s*accounts?/i,
+ /只\s*Star|只\s*star\s*不超过|Stargazer\s*列表|抽查了?\s*\d+\s*个账号/i
+ ];
+
+ const reportThreatPatterns = [
+ /违规|举报|上报|报告给\s*GitHub|发往\s*GitHub/i,
+ /违规举报|举报到位|举报已递|举报已提交|举报交官方/i,
+ /违规流量|违规人气|清理人气|清除流量|流量清除/i,
+ /清零|清空|封禁|封禁项目|关闭项目|项目关停|项目关闭|项目下线/i,
+ /关停下线|入口关闭|归档备查|违规已定/i
+ ];
+
+ const metricMismatchPatterns = [
+ /(npm|pip|下载量).{0,80}(Star|stars?|星)/i,
+ /(Star|stars?|星).{0,80}(npm|pip|下载量)/i,
+ /(Issue|PR|Fork|贡献者|社区活跃|活跃指标|月均).{0,80}(不匹配|太低|很低|对比|同类项目)/i,
+ /(同类项目|类似项目|对比关键指标|关键指标|指标).{0,80}(Star|stars?|星)/i,
+ /只有\s*Star\s*高|其他指标都远低于同类/i,
+ /真实用户|真实反馈|真实内容|真实评价/i
+ ];
+
+ const reputationAttackPatterns = [
+ /诚信问题|诚信有问题|信任崩塌|失望|反面教材/i,
+ /简历|面试|候选人|找工作|pass\s*的|直接\s*pass/i,
+ /技术分享|教学案例|识别刷星项目/i,
+ /真实口碑|虚假热度|虚假的数字|真实用户的认可/i
+ ];
+
+ const strongKeywordHit = hit(strongStarFarmPatterns);
+ const hasStarContext = hit(starContextPatterns);
+ const abnormalGrowthHit = hit(abnormalGrowthPatterns);
+ const suspiciousAccountHit = hit(suspiciousAccountPatterns);
+ const reportThreatHit = hit(reportThreatPatterns);
+ const metricMismatchHit = hit(metricMismatchPatterns);
+ const reputationAttackHit = hit(reputationAttackPatterns);
+
+ const starAccusationScore = [
+ abnormalGrowthHit,
+ suspiciousAccountHit,
+ reportThreatHit,
+ metricMismatchHit,
+ reputationAttackHit
+ ].filter(Boolean).length;
+
+ const coordinatedThreatWithoutStarHit =
+ suspiciousAccountHit &&
+ reportThreatHit &&
+ /注册日期集中|注册时间集中|创建时间集中|最近\s*(两|2)\s*周|随机字符串|空壳号|无公开仓库|没有仓库|无粉丝|没有\s*followers?/i.test(normalizedIssueText);
+
+ const keywordHit =
+ strongKeywordHit ||
+ (hasStarContext && starAccusationScore >= 2) ||
+ coordinatedThreatWithoutStarHit;
+
+ let priorActivity = false;
+
+ try {
+ const authoredSearch = await github.rest.search.issuesAndPullRequests({
+ q: `repo:${owner}/${repo} author:${author}`,
+ per_page: 20
+ });
+
+ const priorAuthoredItems = authoredSearch.data.items.filter(
+ item => item.number !== issue.number
+ );
+
+ if (priorAuthoredItems.length > 0) {
+ priorActivity = true;
+ }
+ } catch (error) {
+ core.warning(`Failed to search authored issues/PRs: ${error.message}`);
+ }
+
+ try {
+ const commentedSearch = await github.rest.search.issuesAndPullRequests({
+ q: `repo:${owner}/${repo} commenter:${author}`,
+ per_page: 20
+ });
+
+ const priorCommentedItems = commentedSearch.data.items.filter(
+ item => item.number !== issue.number
+ );
+
+ if (priorCommentedItems.length > 0) {
+ priorActivity = true;
+ }
+ } catch (error) {
+ core.warning(`Failed to search commented issues/PRs: ${error.message}`);
+ }
+
+ const suspicious =
+ priorActivity === false &&
+ isNewAccount === true &&
+ keywordHit === true;
+
+ core.info(`Issue number: ${issue.number}`);
+ core.info(`Issue title: ${issue.title}`);
+ core.info(`Author: ${author}`);
+ core.info(`Association: ${association}`);
+ core.info(`Account age days: ${accountAgeDays}`);
+ core.info(`Is new account within ${newAccountDays} days: ${isNewAccount}`);
+ core.info(`Prior activity in repo: ${priorActivity}`);
+ core.info(`Strong keyword hit: ${strongKeywordHit}`);
+ core.info(`Has star context: ${hasStarContext}`);
+ core.info(`Abnormal growth hit: ${abnormalGrowthHit}`);
+ core.info(`Suspicious account hit: ${suspiciousAccountHit}`);
+ core.info(`Report/threat hit: ${reportThreatHit}`);
+ core.info(`Metric mismatch hit: ${metricMismatchHit}`);
+ core.info(`Reputation attack hit: ${reputationAttackHit}`);
+ core.info(`Star accusation score: ${starAccusationScore}`);
+ core.info(`Coordinated threat without star hit: ${coordinatedThreatWithoutStarHit}`);
+ core.info(`Final keyword hit: ${keywordHit}`);
+ core.info(`Suspicious: ${suspicious}`);
+
+ if (!suspicious) {
+ return;
+ }
+
+ const labelName = process.env.LABEL_NAME || "pending-maintainer-review";
+ const labelColor = process.env.LABEL_COLOR || "cfd3d7";
+ const autoClose = process.env.AUTO_CLOSE === "true";
+
+ try {
+ await github.rest.issues.createLabel({
+ owner,
+ repo,
+ name: labelName,
+ color: labelColor,
+ description: "Automatically closed by anti-spam guard; pending maintainer review"
+ });
+ } catch (error) {
+ if (error.status !== 422) {
+ core.warning(`Failed to create label: ${error.message}`);
+ }
+ }
+
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: issue.number,
+ labels: [labelName]
+ });
+
+ const commentBody = [
+ "👋 This issue was automatically closed by an anti-spam guard because it was opened by a recently created GitHub account with **no prior activity in this repository**, and the issue content matches a recent pattern of repetitive star-farming accusations.",
+ "",
+ "If you are a real user with a genuine report, sorry for the friction — just **leave a comment below** explaining your situation and a maintainer will reopen it.",
+ "",
+ "---",
+ "",
+ "本 Issue 由反刷屏自动规则关闭:提交账号为近期注册账号,且**从未与本仓库互动**,同时 Issue 内容匹配近期重复出现的刷星举报模式。如果你是真实用户,请在下方留言说明,维护者会重新开启。"
+ ].join("\n");
+
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: issue.number,
+ body: commentBody
+ });
+
+ if (autoClose) {
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number: issue.number,
+ state: "closed",
+ state_reason: "not_planned"
+ });
+ }
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 9589c41f..8a198ffa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,7 @@ ENV/
# Test / coverage
.pytest_cache/
+.testmondata*
.mypy_cache/
.ruff_cache/
.coverage
@@ -90,3 +91,4 @@ scripts/dev-local-link.sh
# Local agent planning docs (not for upstream)
docs/superpowers/
scripts/lh_image_install_octop_tencentos.sh
+.worktrees/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eacf11a1..5f96ff10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,12 +6,44 @@
## [Unreleased]
+## [0.9.24] - 2026-08-15
+
+### 新增
+- 知识库:新增知识库与对话检索,支持本地 ONNX 向量嵌入模型运行
+- 认证:新增 OpenID Connect(OIDC)单点登录
+- 权限:新增按用户模块权限(RBAC)及管理员绕过
+- 专家:支持将工作区快照发布为可安装模板(专家市场)
+- 智能体:支持将智能体共享给其他用户
+- 技能:新增对话式技能管理器(SkillHub),并兼容 Windows
+- 备份:新增自动定时系统备份
+- 频道:新增 final-only 仅终稿回复模式
+- 线程:支持会话线程分叉(fork)
+- 体验:HITL 工具选择器、运行时按需安装、KB/技能 UX 优化;对话与镜像等界面打磨;知识嵌入初始化流程加固
+
+### 修复
+- 媒体/预览白名单补充音频 MIME 类型
+- 修正 ONNX 下载检测在未安装 fastembed 时的误判
+- 加固更新状态缓存与存储处理
+- 预提交门控:修复 staged 变更检测,避免 testmon 门控误报为绿
+
+### 变更
+- 升级 harness-browser 依赖至 0.7.5
+- 数据库 schema 收敛为 v5
+- 备份恢复面板图标更新为 CalendarClock;对话/镜像等界面打磨
+
## [0.9.23] - 2026-08-13
### 修复
- 取消首次引导时删除 `octop-login.txt` 引导密码文件的逻辑,避免引导密码意外丢失
- 修复安装脚本版本显示问题,并将安装输出调整为英文
+### 新增
+
+- Octop-owned built-in `skill-manager` for conversational Skill lifecycle
+ management from uploaded files, archives, Git/GitHub or web URLs, and
+ SkillHub. It is seeded into every agent instance without modifying
+ harness-agent and installs user Skills only under that agent's `skills/`.
+
## [0.9.22] - 2026-08-11
### 新增
diff --git a/Makefile b/Makefile
index 6a72cdd4..37ac43ee 100644
--- a/Makefile
+++ b/Makefile
@@ -58,6 +58,7 @@ help:
@echo ""
@echo "Quality targets (ship bar):"
@echo " all format-all + lint + typecheck + test (backend lint/typecheck/test)"
+ @echo " precommit fast change-aware gate for the git pre-commit hook (testmon-scoped tests)"
@echo " lint Ruff check + format check (src, tests)"
@echo " format Ruff auto-fix + format (src, tests)"
@echo " typecheck mypy --strict src/octop"
@@ -226,6 +227,29 @@ test-live:
@echo "[test] pytest (live)..."
$(RUN) pytest -m live
+# Fast, change-aware gate for the local pre-commit hook. Runs ruff/mypy (cheap
+# and kept full for accuracy) plus only the tests affected by changed files via
+# pytest-testmon. The full suite still runs in CI (`make all`); this is local
+# feedback only, so a missed cross-module impact is caught there.
+.PHONY: precommit
+precommit: format-all lint typecheck test-affected
+
+# NOTE: do NOT pass `-m` here — pytest-testmon deactivates its affected-test
+# selection whenever a marker expression is present, which would fall back to
+# the full suite. Live tests under tests/live/ auto-skip when credentials are
+# absent, so running them unscoped is cheap and safe; the CI `make all` gate
+# keeps `-m "not live"` for the authoritative full run.
+#
+# NOTE: testmon detects changes via `git ls-files -m` (worktree vs index), which
+# is empty once changes are staged — exactly the pre-commit state — so it would
+# silently run zero tests. The patch in conftest.py / tests/support/
+# testmon_staged_changes.py redirects detection to `git diff HEAD` (staged +
+# unstaged) so the gate actually fires on a commit's changes.
+.PHONY: test-affected
+test-affected:
+ @echo "[test] pytest (testmon: only tests affected by changes)..."
+ $(RUN) pytest --testmon
+
# ─── Quality (frontend) ──────────────────────────────────────────────────────
.PHONY: lint-frontend
@@ -266,7 +290,7 @@ install-hooks:
@echo "[install-hooks] Setting core.hooksPath=.githooks"
git config core.hooksPath .githooks
@chmod +x "$(REPO_ROOT)/.githooks/"* 2>/dev/null || true
- @echo "[install-hooks] Done. Pre-commit will run: make all (incl. format-all), dashboard build"
+ @echo "[install-hooks] Done. Pre-commit will run: make precommit (format-all + lint + typecheck + testmon-scoped tests), dashboard build"
@echo "[install-hooks] Bypass: SKIP_PRECOMMIT=1 git commit … or git commit --no-verify"
.PHONY: install install-dev
diff --git a/README.md b/README.md
index 38595c30..f39a2a8a 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,12 @@
-
+
+
+
@@ -186,6 +188,14 @@ See [scripts/README.md](scripts/README.md) for all install options (`--version`,
```bash
pip install octop
# optional: pip install "octop[browser]"
+# optional local ONNX embedding model cache (Models → Local): pip install "octop[local-embedding]"
+# Downloads catalog weights under ~/.octop/embedding_models; not chat, not Memory.
+```
+
+From a source checkout with uv:
+
+```bash
+uv sync --extra local-embedding
```
### 2. Initialize
diff --git a/README_CN.md b/README_CN.md
index 525c1f35..9fc11fa3 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -12,6 +12,8 @@
+
+
@@ -197,6 +199,14 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install
```bash
pip install octop
# 可选:pip install "octop[browser]"
+# 可选本地 ONNX Embedding 模型缓存(设置 → 模型 → 本地):pip install "octop[local-embedding]"
+# 仅下载目录模型到 ~/.octop/embedding_models;不用于对话,也不接入 Memory。
+```
+
+从源码用 uv 开发时:
+
+```bash
+uv sync --extra local-embedding
```
### 2. 启动
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 00000000..5257fdd8
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,11 @@
+"""Repo-root conftest.
+
+Imported by pytest at startup (before any plugin's ``pytest_configure``), so the
+testmon change-detection patch in ``tests.support.testmon_staged_changes`` is
+applied before testmon reads file fingerprints. The patch only affects
+testmon's internals and is a safe no-op when testmon is absent.
+"""
+
+from __future__ import annotations
+
+import tests.support.testmon_staged_changes # noqa: F401 (applies the patch)
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 62b6544a..76614d63 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -5,6 +5,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useTranslation } from "react-i18next";
import MainLayout from "./layouts/MainLayout";
import LoginPage from "./pages/Login";
+import OidcComplete from "./pages/Login/OidcComplete";
import SetupPage from "./pages/Setup";
import AuthGuard from "./components/AuthGuard";
import { AntdAppProvider } from "./components/AntdAppProvider";
@@ -99,6 +100,7 @@ function ThemedApp() {
} />
+ } />
} />
request("/auth/oidc/status"),
+
+ /** Start an OIDC authorization-code login flow. */
+ startOidc: (redirect_after?: string) =>
+ request<{ authorization_url: string }>("/auth/oidc/start", {
+ method: "POST",
+ body: JSON.stringify({ redirect_after }),
+ }),
+
+ /** Exchange the one-time browser code for the standard JWT login response. */
+ exchangeOidcCode: async (code: string): Promise => {
+ const raw = await request("/auth/oidc/exchange", {
+ method: "POST",
+ body: JSON.stringify({ code }),
+ });
+ return { ...raw, token: raw.access_token };
+ },
+
/** Server-side logout (best-effort; client clears token regardless). */
logout: () =>
request("/auth/logout", { method: "POST" }).catch(() => undefined),
diff --git a/dashboard/src/api/modules/backup.ts b/dashboard/src/api/modules/backup.ts
index 3861b9df..712c8a01 100644
--- a/dashboard/src/api/modules/backup.ts
+++ b/dashboard/src/api/modules/backup.ts
@@ -12,9 +12,33 @@ export interface BackupListResponse {
items: BackupFileItem[];
}
+export interface AutoBackupSettings {
+ auto_enabled: boolean;
+ schedule: string;
+ retention_count: number;
+ scheduled?: boolean;
+}
+
export const backupApi = {
listBackups: () => request("/admin/backup/list"),
+ getAutoSettings: () => request("/admin/backup/auto"),
+
+ updateAutoSettings: (body: {
+ auto_enabled: boolean;
+ schedule: string;
+ retention_count: number;
+ }) =>
+ request<{ ok: boolean } & AutoBackupSettings>("/admin/backup/auto", {
+ method: "PUT",
+ body: JSON.stringify(body),
+ }),
+
+ runAutoBackup: () =>
+ request<{ ok: boolean; item: BackupFileItem }>("/admin/backup/auto/run", {
+ method: "POST",
+ }),
+
createBackup: () =>
request<{ ok: boolean; item: BackupFileItem }>("/admin/backup/create", {
method: "POST",
diff --git a/dashboard/src/api/modules/expertMarket.ts b/dashboard/src/api/modules/expertMarket.ts
index 61be8555..8e9594a8 100644
--- a/dashboard/src/api/modules/expertMarket.ts
+++ b/dashboard/src/api/modules/expertMarket.ts
@@ -61,6 +61,13 @@ export interface CreateMarketExpertBody {
providers?: string[];
default_model?: string;
backend?: Record;
+ skill_package_ids?: string[];
+ color?: string;
+ max_iters?: number | null;
+ max_input_length?: number | null;
+ temperature?: number | null;
+ top_p?: number | null;
+ max_tokens?: number | null;
}
function hubListPath(query: string, scene: string): string {
diff --git a/dashboard/src/api/modules/knowledgeBases.test.ts b/dashboard/src/api/modules/knowledgeBases.test.ts
new file mode 100644
index 00000000..9c3b9ec9
--- /dev/null
+++ b/dashboard/src/api/modules/knowledgeBases.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { request, requestUpload } = vi.hoisted(() => ({
+ request: vi.fn(),
+ requestUpload: vi.fn(),
+}));
+
+vi.mock("../request", () => ({ request, requestUpload }));
+
+import { knowledgeBasesApi } from "./knowledgeBases";
+
+beforeEach(() => {
+ request.mockClear();
+ requestUpload.mockClear();
+});
+
+describe("knowledgeBasesApi", () => {
+ it("uses the knowledge-base capability and feature endpoints", () => {
+ knowledgeBasesApi.getCapability();
+ knowledgeBasesApi.setFeature({ enabled: true, model: "BAAI/bge-small" });
+ knowledgeBasesApi.downloadOnnx("BAAI/bge-small-zh-v1.5");
+ knowledgeBasesApi.getOnnxDownloadStatus();
+ knowledgeBasesApi.activateOnnx("BAAI/bge-small-zh-v1.5");
+
+ expect(request).toHaveBeenNthCalledWith(1, "/knowledge-bases/capability");
+ expect(request).toHaveBeenNthCalledWith(2, "/knowledge-bases/feature", {
+ method: "PUT",
+ body: JSON.stringify({ enabled: true, model: "BAAI/bge-small" }),
+ });
+ expect(request).toHaveBeenNthCalledWith(
+ 3,
+ "/knowledge-bases/onnx-download",
+ {
+ method: "POST",
+ body: JSON.stringify({ model: "BAAI/bge-small-zh-v1.5" }),
+ },
+ );
+ expect(request).toHaveBeenNthCalledWith(
+ 4,
+ "/knowledge-bases/onnx-download-status",
+ );
+ expect(request).toHaveBeenNthCalledWith(
+ 5,
+ "/knowledge-bases/onnx-activate",
+ {
+ method: "POST",
+ body: JSON.stringify({ model: "BAAI/bge-small-zh-v1.5" }),
+ },
+ );
+ });
+
+ it("requests the full ONNX catalog when expanding embedding options", () => {
+ knowledgeBasesApi.getEmbeddingOptions();
+ knowledgeBasesApi.getEmbeddingOptions({ allOnnx: true });
+
+ expect(request).toHaveBeenNthCalledWith(
+ 1,
+ "/knowledge-bases/embedding-options",
+ );
+ expect(request).toHaveBeenNthCalledWith(
+ 2,
+ "/knowledge-bases/embedding-options?all_onnx=true",
+ );
+ });
+
+ it("uses nested document endpoints", () => {
+ const file = new File(["document"], "notes.md", { type: "text/markdown" });
+ knowledgeBasesApi.uploadDocument("kb-1", file);
+ knowledgeBasesApi.deleteDocument("kb-1", "doc-1");
+ knowledgeBasesApi.previewDocument("kb-1", "doc-1");
+
+ expect(requestUpload).toHaveBeenCalledWith(
+ "/knowledge-bases/kb-1/documents",
+ expect.any(FormData),
+ { method: "POST" },
+ );
+ expect(request).toHaveBeenNthCalledWith(
+ 1,
+ "/knowledge-bases/kb-1/documents/doc-1",
+ { method: "DELETE" },
+ );
+ expect(request).toHaveBeenNthCalledWith(
+ 2,
+ "/knowledge-bases/kb-1/documents/doc-1/preview",
+ );
+ });
+});
diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts
new file mode 100644
index 00000000..ddf4d6fe
--- /dev/null
+++ b/dashboard/src/api/modules/knowledgeBases.ts
@@ -0,0 +1,195 @@
+import { request, requestUpload } from "../request";
+
+export interface KnowledgeLimits {
+ max_bases_per_owner: number;
+ max_docs_per_kb: number;
+ max_document_bytes: number;
+}
+
+export interface KnowledgeCapability {
+ feature_enabled: boolean;
+ backend: "onnx" | "remote";
+ selected_model: string;
+ provider_id: string;
+ prerequisites_ok: boolean;
+ usable: boolean;
+ checks: {
+ model_selected: boolean;
+ model_downloaded: boolean;
+ deps_available: boolean;
+ provider_ready: boolean;
+ };
+ limits?: KnowledgeLimits;
+}
+
+export interface KnowledgeBase {
+ id: string;
+ owner_user_id: number;
+ owner_username?: string | null;
+ owner_display_name?: string | null;
+ name: string;
+ description: string;
+ default_open: boolean;
+ shared: boolean;
+ icon_name: string;
+ embedding_model: string;
+ embedding_dim: number;
+ doc_count: number;
+ created_at: number;
+ updated_at: number;
+}
+
+export interface KnowledgeDocument {
+ id: string;
+ kb_id: string;
+ filename: string;
+ content_type: string;
+ byte_size: number;
+ content_hash: string;
+ status: "pending" | "processing" | "ready" | "failed";
+ error_message: string;
+ chunk_count: number;
+ created_at: number;
+ updated_at: number;
+}
+
+export interface KnowledgeOnnxModel {
+ id: string;
+ name: string;
+ downloaded: boolean;
+ recommended?: boolean;
+ size_gb?: number | null;
+}
+
+export interface KnowledgeEmbeddingOptions {
+ onnx: KnowledgeOnnxModel[];
+ remote: {
+ provider_id: string;
+ provider_name: string;
+ models: { id: string; name: string }[];
+ }[];
+}
+
+export interface KnowledgeOnnxDownloadState {
+ status: "idle" | "downloading" | "loading" | "done" | "failed";
+ progress: number;
+ error?: string | null;
+ model_name: string;
+ task_id?: string;
+}
+
+export const DEFAULT_KNOWLEDGE_LIMITS: KnowledgeLimits = {
+ max_bases_per_owner: 20,
+ max_docs_per_kb: 100,
+ max_document_bytes: 20 * 1024 * 1024,
+};
+
+export const knowledgeBasesApi = {
+ getCapability: () =>
+ request("/knowledge-bases/capability"),
+
+ setFeature: (body: {
+ enabled: boolean;
+ backend?: "onnx" | "remote";
+ model?: string;
+ provider_id?: string;
+ }) =>
+ request("/knowledge-bases/feature", {
+ method: "PUT",
+ body: JSON.stringify(body),
+ }),
+
+ list: () => request("/knowledge-bases"),
+ getEmbeddingOptions: (opts?: { allOnnx?: boolean }) =>
+ request(
+ opts?.allOnnx
+ ? "/knowledge-bases/embedding-options?all_onnx=true"
+ : "/knowledge-bases/embedding-options",
+ ),
+
+ downloadOnnx: (model: string) =>
+ request("/knowledge-bases/onnx-download", {
+ method: "POST",
+ body: JSON.stringify({ model }),
+ }),
+
+ getOnnxDownloadStatus: () =>
+ request(
+ "/knowledge-bases/onnx-download-status",
+ ),
+
+ activateOnnx: (model: string) =>
+ request<{ enabled: boolean; model: string; ready: boolean }>(
+ "/knowledge-bases/onnx-activate",
+ {
+ method: "POST",
+ body: JSON.stringify({ model }),
+ },
+ ),
+
+ get: (id: string) => request(`/knowledge-bases/${id}`),
+
+ create: (body: {
+ name: string;
+ description?: string;
+ default_open?: boolean;
+ shared?: boolean;
+ icon_name?: string;
+ }) =>
+ request("/knowledge-bases", {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+
+ update: (
+ id: string,
+ body: {
+ name?: string;
+ description?: string;
+ default_open?: boolean;
+ shared?: boolean;
+ icon_name?: string;
+ },
+ ) =>
+ request(`/knowledge-bases/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+
+ delete: (id: string) =>
+ request(`/knowledge-bases/${id}`, { method: "DELETE" }),
+
+ listDocuments: (id: string) =>
+ request(`/knowledge-bases/${id}/documents`),
+
+ uploadDocument: (id: string, file: File) => {
+ const body = new FormData();
+ body.append("upload", file);
+ return requestUpload(
+ `/knowledge-bases/${id}/documents`,
+ body,
+ { method: "POST" },
+ );
+ },
+
+ deleteDocument: (id: string, documentId: string) =>
+ request(`/knowledge-bases/${id}/documents/${documentId}`, {
+ method: "DELETE",
+ }),
+
+ reindexDocument: (id: string, documentId: string) =>
+ request(
+ `/knowledge-bases/${id}/documents/${documentId}/reindex`,
+ { method: "POST" },
+ ),
+
+ previewDocument: (id: string, documentId: string) =>
+ request<{ id: string; filename: string; text: string }>(
+ `/knowledge-bases/${id}/documents/${documentId}/preview`,
+ ),
+
+ reindex: (id: string) =>
+ request<{ enqueued: number }>(`/knowledge-bases/${id}/reindex`, {
+ method: "POST",
+ }),
+};
diff --git a/dashboard/src/api/modules/octopThreads.ts b/dashboard/src/api/modules/octopThreads.ts
index b14ff4f9..98eacf31 100644
--- a/dashboard/src/api/modules/octopThreads.ts
+++ b/dashboard/src/api/modules/octopThreads.ts
@@ -152,6 +152,30 @@ export const octopThreadsApi = {
{ method: "DELETE" },
),
+ fork: (
+ agentId: string,
+ threadId: string,
+ body: {
+ message_id: string;
+ content?: string;
+ user_turns_from_end?: number;
+ },
+ ) =>
+ request<{
+ thread_id: string;
+ session_key: string;
+ source_thread_id: string;
+ copied_messages: number;
+ title: string | null;
+ last_active: number;
+ created_at: number;
+ }>(
+ `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(
+ threadId,
+ )}/fork`,
+ { method: "POST", body: JSON.stringify(body) },
+ ),
+
markRead: (agentId: string, threadId: string) =>
request(
`/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(
diff --git a/dashboard/src/api/modules/ollamaModel.ts b/dashboard/src/api/modules/ollamaModel.ts
index a29d5851..4d330b53 100644
--- a/dashboard/src/api/modules/ollamaModel.ts
+++ b/dashboard/src/api/modules/ollamaModel.ts
@@ -28,4 +28,13 @@ export const ollamaModelApi = {
`/ollama-models/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
+
+ getService: () =>
+ request<{ enabled: boolean; running: boolean }>("/ollama-models/service"),
+
+ setService: (enabled: boolean) =>
+ request<{ enabled: boolean; running: boolean }>("/ollama-models/service", {
+ method: "PUT",
+ body: JSON.stringify({ enabled }),
+ }),
};
diff --git a/dashboard/src/api/modules/onnxDownloadWatcher.ts b/dashboard/src/api/modules/onnxDownloadWatcher.ts
new file mode 100644
index 00000000..aba5196d
--- /dev/null
+++ b/dashboard/src/api/modules/onnxDownloadWatcher.ts
@@ -0,0 +1,116 @@
+/**
+ * Background watcher for ONNX model downloads.
+ * Survives closing the progress / config modal; notifies once on completion.
+ */
+import { onnxModelApi, type OnnxDownloadState } from "./onnxModel";
+
+export type OnnxDownloadProgressHandler = (state: OnnxDownloadState) => void;
+export type OnnxDownloadTerminalHandler = (
+ state: OnnxDownloadState,
+) => void | Promise;
+
+interface WatchOptions {
+ modelId: string;
+ onProgress?: OnnxDownloadProgressHandler;
+ onTerminal: OnnxDownloadTerminalHandler;
+ intervalMs?: number;
+ maxTicks?: number;
+}
+
+interface ActiveWatch {
+ modelId: string;
+ timer: ReturnType;
+ onProgress?: OnnxDownloadProgressHandler;
+ onTerminal: OnnxDownloadTerminalHandler;
+ ticks: number;
+ maxTicks: number;
+ finished: boolean;
+}
+
+let active: ActiveWatch | null = null;
+
+export function isWatchingOnnxDownload(modelId?: string): boolean {
+ if (!active || active.finished) return false;
+ if (modelId) return active.modelId === modelId;
+ return true;
+}
+
+export function getActiveOnnxDownloadModelId(): string | null {
+ return active && !active.finished ? active.modelId : null;
+}
+
+/** Attach/replace UI progress listener without restarting the poll. */
+export function setOnnxDownloadProgressHandler(
+ handler: OnnxDownloadProgressHandler | undefined,
+): void {
+ if (active) active.onProgress = handler;
+}
+
+export function stopWatchingOnnxDownload(): void {
+ if (!active) return;
+ clearInterval(active.timer);
+ active = null;
+}
+
+export function watchOnnxDownload(opts: WatchOptions): void {
+ const {
+ modelId,
+ onProgress,
+ onTerminal,
+ intervalMs = 500,
+ maxTicks = 600,
+ } = opts;
+
+ stopWatchingOnnxDownload();
+
+ const watch: ActiveWatch = {
+ modelId,
+ onProgress,
+ onTerminal,
+ ticks: 0,
+ maxTicks,
+ finished: false,
+ timer: setInterval(() => {
+ void tick();
+ }, intervalMs),
+ };
+ active = watch;
+
+ async function tick(): Promise {
+ if (!active || active !== watch || watch.finished) return;
+ watch.ticks += 1;
+ try {
+ const state = await onnxModelApi.getDownloadStatus();
+ watch.onProgress?.(state);
+ if (state.status === "done" || state.status === "failed") {
+ await finish(state);
+ return;
+ }
+ if (watch.ticks >= watch.maxTicks) {
+ await finish({
+ status: "failed",
+ progress: state.progress ?? 0,
+ error: "download timed out",
+ model_name: modelId,
+ });
+ }
+ } catch (err) {
+ await finish({
+ status: "failed",
+ progress: 0,
+ error: err instanceof Error ? err.message : String(err),
+ model_name: modelId,
+ });
+ }
+ }
+
+ async function finish(state: OnnxDownloadState): Promise {
+ if (watch.finished) return;
+ watch.finished = true;
+ clearInterval(watch.timer);
+ if (active === watch) active = null;
+ await watch.onTerminal(state);
+ }
+
+ void tick();
+}
diff --git a/dashboard/src/api/modules/onnxModel.ts b/dashboard/src/api/modules/onnxModel.ts
new file mode 100644
index 00000000..e88a4e01
--- /dev/null
+++ b/dashboard/src/api/modules/onnxModel.ts
@@ -0,0 +1,90 @@
+import { request } from "../request";
+
+export interface OnnxCatalogItem {
+ id: string;
+ name: string;
+ recommended: boolean;
+ downloaded: boolean;
+ size_gb?: number | null;
+ hf_source?: string | null;
+}
+
+export interface OnnxModelMeta {
+ id: string;
+ size_gb?: number | null;
+ hf_source?: string | null;
+ supported?: boolean;
+ downloaded?: boolean;
+}
+
+export interface OnnxDownloadState {
+ status: "idle" | "downloading" | "loading" | "done" | "failed";
+ progress: number;
+ error?: string | null;
+ model_name: string;
+ task_id?: string;
+}
+
+export interface OnnxServiceStatus {
+ enabled: boolean;
+ model: string;
+ ready: boolean;
+ downloaded: boolean;
+ cache_dir: string;
+ download: OnnxDownloadState;
+ local_models: string[];
+ presets: string[];
+ download_started?: boolean;
+ deps_available?: boolean;
+ deps_just_installed?: boolean;
+}
+
+export const onnxModelApi = {
+ getCatalog: () => request("/onnx-models/catalog"),
+ getModelMeta: (model: string) =>
+ request(
+ `/onnx-models/models/${encodeURIComponent(model)}/meta`,
+ ),
+ getStatus: () => request("/onnx-models/status"),
+ updateConfig: (body: {
+ enabled: boolean;
+ model: string;
+ download_if_missing?: boolean;
+ }) =>
+ request("/onnx-models/config", {
+ method: "PUT",
+ body: JSON.stringify(body),
+ }),
+ download: (model: string) =>
+ request("/onnx-models/download", {
+ method: "POST",
+ body: JSON.stringify({ model }),
+ }),
+ test: (model: string) =>
+ request<{
+ ok: boolean;
+ latency_ms?: number | null;
+ error?: string | null;
+ dim?: number | null;
+ }>("/onnx-models/test", {
+ method: "POST",
+ body: JSON.stringify({ model }),
+ }),
+ getDownloadStatus: () =>
+ request("/onnx-models/download-status"),
+ deleteLocal: (model: string) =>
+ request<{ ok: boolean; removed: boolean; status: OnnxServiceStatus }>(
+ `/onnx-models/local/${encodeURIComponent(model)}`,
+ { method: "DELETE" },
+ ),
+
+ /** Toggle local ONNX service without forcing a download. */
+ setService: async (enabled: boolean) => {
+ const st = await onnxModelApi.getStatus();
+ return onnxModelApi.updateConfig({
+ enabled,
+ model: st.model || st.presets[0] || "BAAI/bge-small-zh-v1.5",
+ download_if_missing: false,
+ });
+ },
+};
diff --git a/dashboard/src/api/modules/publishedExperts.test.ts b/dashboard/src/api/modules/publishedExperts.test.ts
new file mode 100644
index 00000000..ecad37c6
--- /dev/null
+++ b/dashboard/src/api/modules/publishedExperts.test.ts
@@ -0,0 +1,74 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { request } = vi.hoisted(() => ({ request: vi.fn() }));
+
+vi.mock("../request", () => ({ request }));
+
+import { publishedExpertsApi } from "./publishedExperts";
+
+beforeEach(() => {
+ request.mockClear();
+});
+
+describe("publishedExpertsApi", () => {
+ it("uses publish lifecycle endpoints for an agent-owned template", () => {
+ publishedExpertsApi.list();
+ publishedExpertsApi.publish("agent-1", {
+ name: "Research assistant",
+ description: "Finds sources",
+ slug: "research-assistant",
+ });
+ publishedExpertsApi.refresh("expert/1", {
+ name: "Updated",
+ description: "New description",
+ welcome_message: { zh: "欢迎", en: "Welcome" },
+ });
+ publishedExpertsApi.unpublish("expert/1");
+
+ expect(request).toHaveBeenNthCalledWith(1, "/experts/published");
+ expect(request).toHaveBeenNthCalledWith(
+ 2,
+ "/agents/agent-1/publish-expert",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ name: "Research assistant",
+ description: "Finds sources",
+ slug: "research-assistant",
+ }),
+ },
+ );
+ expect(request).toHaveBeenNthCalledWith(
+ 3,
+ "/experts/published/expert%2F1/refresh",
+ {
+ method: "POST",
+ },
+ );
+ expect(request).toHaveBeenNthCalledWith(
+ 4,
+ "/experts/published/expert%2F1",
+ {
+ method: "DELETE",
+ },
+ );
+ });
+
+ it("installs a published template with its chosen agent details", () => {
+ publishedExpertsApi.install("expert-1", {
+ name: "My research assistant",
+ description: "Personal copy",
+ });
+
+ expect(request).toHaveBeenCalledWith(
+ "/experts/published/expert-1/install",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ name: "My research assistant",
+ description: "Personal copy",
+ }),
+ },
+ );
+ });
+});
diff --git a/dashboard/src/api/modules/publishedExperts.ts b/dashboard/src/api/modules/publishedExperts.ts
new file mode 100644
index 00000000..eb61f1ce
--- /dev/null
+++ b/dashboard/src/api/modules/publishedExperts.ts
@@ -0,0 +1,77 @@
+import { request } from "../request";
+
+export interface PublishedExpert {
+ id: string;
+ slug: string;
+ name: string;
+ description: string;
+ created_by: string;
+ creator_username: string | null;
+ source_agent_id: string | null;
+ icon_name: string | null;
+ color: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface PublishExpertBody {
+ name: string;
+ description?: string;
+ slug?: string;
+ welcome_message?: { zh?: string; en?: string };
+}
+
+export type RefreshPublishedExpertBody = PublishExpertBody;
+
+export interface InstallPublishedExpertBody {
+ name: string;
+ description?: string;
+ providers?: string[];
+ default_model?: string;
+ backend?: Record;
+ skill_package_ids?: string[];
+ color?: string;
+ max_iters?: number | null;
+ max_input_length?: number | null;
+ temperature?: number | null;
+ top_p?: number | null;
+ max_tokens?: number | null;
+}
+
+export interface InstalledPublishedExpert {
+ agent_id: string;
+ name: string;
+ description: string | null;
+ published_expert_id: string;
+}
+
+const publishedPath = (expertId: string) =>
+ `/experts/published/${encodeURIComponent(expertId)}`;
+
+export const publishedExpertsApi = {
+ list: () => request("/experts/published"),
+
+ publish: (agentId: string, body: PublishExpertBody) =>
+ request(
+ `/agents/${encodeURIComponent(agentId)}/publish-expert`,
+ {
+ method: "POST",
+ body: JSON.stringify(body),
+ },
+ ),
+
+ refresh: (expertId: string, body?: RefreshPublishedExpertBody) =>
+ request(`${publishedPath(expertId)}/refresh`, {
+ method: "POST",
+ body: body ? JSON.stringify(body) : undefined,
+ }),
+
+ unpublish: (expertId: string) =>
+ request(publishedPath(expertId), { method: "DELETE" }),
+
+ install: (expertId: string, body: InstallPublishedExpertBody) =>
+ request(`${publishedPath(expertId)}/install`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+};
diff --git a/dashboard/src/api/modules/security.ts b/dashboard/src/api/modules/security.ts
index 190669dd..f6b1dd19 100644
--- a/dashboard/src/api/modules/security.ts
+++ b/dashboard/src/api/modules/security.ts
@@ -60,6 +60,18 @@ export interface ToolGuardRulesSaveResponse {
rule_count: number;
}
+export interface HitlToolCatalogItem {
+ name: string;
+ label_zh: string;
+ label_en: string;
+}
+
+export interface SecurityDefaults {
+ hitl_tools: string[];
+ hitl_tool_catalog: HitlToolCatalogItem[];
+ tool_guard_rules: ToolGuardRule[];
+}
+
export interface SecurityPolicy {
hitl: HitlPolicy;
filesystem: FilesystemPolicy;
@@ -103,4 +115,7 @@ export const securityApi = {
},
);
},
+ getDefaults(): Promise {
+ return request("/admin/security/defaults");
+ },
};
diff --git a/dashboard/src/api/modules/sso.test.ts b/dashboard/src/api/modules/sso.test.ts
new file mode 100644
index 00000000..3615509b
--- /dev/null
+++ b/dashboard/src/api/modules/sso.test.ts
@@ -0,0 +1,37 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { request } = vi.hoisted(() => ({ request: vi.fn() }));
+
+vi.mock("../request", () => ({ request }));
+
+import { ssoApi } from "./sso";
+
+describe("ssoApi", () => {
+ beforeEach(() => {
+ request.mockReset();
+ });
+
+ it("uses the admin OIDC configuration endpoints", async () => {
+ const body = {
+ enabled: true,
+ display_name: "Acme SSO",
+ issuer: "https://identity.example.com",
+ client_id: "octop",
+ scopes: "openid profile email",
+ dashboard_origin: "https://octop.example.com",
+ };
+
+ await ssoApi.getOidcConfig();
+ await ssoApi.putOidcConfig(body);
+ await ssoApi.testOidcConfig();
+
+ expect(request).toHaveBeenNthCalledWith(1, "/auth/oidc/config");
+ expect(request).toHaveBeenNthCalledWith(2, "/auth/oidc/config", {
+ method: "PUT",
+ body: JSON.stringify(body),
+ });
+ expect(request).toHaveBeenNthCalledWith(3, "/auth/oidc/config/test", {
+ method: "POST",
+ });
+ });
+});
diff --git a/dashboard/src/api/modules/sso.ts b/dashboard/src/api/modules/sso.ts
new file mode 100644
index 00000000..c6894d2e
--- /dev/null
+++ b/dashboard/src/api/modules/sso.ts
@@ -0,0 +1,44 @@
+import { request } from "../request";
+
+export interface OidcConfig {
+ enabled: boolean;
+ display_name: string;
+ issuer: string;
+ client_id: string;
+ scopes: string;
+ dashboard_origin: string | null;
+ has_client_secret: boolean;
+ redirect_uri?: string;
+}
+
+export interface OidcConfigPut {
+ enabled: boolean;
+ display_name: string;
+ issuer: string;
+ client_id: string;
+ client_secret?: string;
+ scopes: string;
+ dashboard_origin?: string | null;
+}
+
+export interface OidcConfigTestResult {
+ ok: boolean;
+ detail?: string;
+}
+
+export const ssoApi = {
+ getOidcConfig(): Promise {
+ return request("/auth/oidc/config");
+ },
+ putOidcConfig(body: OidcConfigPut): Promise {
+ return request("/auth/oidc/config", {
+ method: "PUT",
+ body: JSON.stringify(body),
+ });
+ },
+ testOidcConfig(): Promise {
+ return request("/auth/oidc/config/test", {
+ method: "POST",
+ });
+ },
+};
diff --git a/dashboard/src/api/modules/subagents.ts b/dashboard/src/api/modules/subagents.ts
index d4a53035..60e20e23 100644
--- a/dashboard/src/api/modules/subagents.ts
+++ b/dashboard/src/api/modules/subagents.ts
@@ -31,6 +31,7 @@ export interface AgentSubagentSummary {
description?: string;
path: string;
emoji?: string;
+ color?: string | null;
}
export function listSubagentDivisions(): Promise {
diff --git a/dashboard/src/api/request.ts b/dashboard/src/api/request.ts
index d57507ee..12697285 100644
--- a/dashboard/src/api/request.ts
+++ b/dashboard/src/api/request.ts
@@ -10,6 +10,8 @@ const AUTH_TOKEN_KEY = "auth_token";
* the SPA alive instead of tearing the document down mid-render.
*/
export const UNAUTHORIZED_EVENT = "octop:unauthorized";
+/** Fired when an agent-scoped API request is forbidden. */
+export const FORBIDDEN_EVENT = "octop:forbidden";
/** Response header used by the server for JWT sliding renewal. */
export const ACCESS_TOKEN_RESPONSE_HEADER = "X-Octop-Access-Token";
@@ -259,6 +261,11 @@ export async function request(
applyRenewedAccessToken(response);
if (!response.ok) {
+ if (response.status === 403 && isAgentScopedPath(path)) {
+ window.dispatchEvent(
+ new CustomEvent(FORBIDDEN_EVENT, { detail: { path } }),
+ );
+ }
const text = await response.text().catch(() => "");
throw new Error(
`Request failed: ${response.status} ${response.statusText}${
diff --git a/dashboard/src/assets/providers/index.ts b/dashboard/src/assets/providers/index.ts
index a6ce0763..806dd73b 100644
--- a/dashboard/src/assets/providers/index.ts
+++ b/dashboard/src/assets/providers/index.ts
@@ -9,6 +9,7 @@ import siliconLogo from "./silicon.png";
import groqLogo from "./groq.png";
import modelscopeLogo from "./modelscope.png";
import ollamaLogo from "./ollama.png";
+import onnxLogo from "./onnx.svg";
import tencentCodingPlanLogo from "./tencent-coding-plan.png";
import tencentTokenPlanLogo from "./tencent-token-plan.png";
import openrouterLogo from "./openrouter.png";
@@ -35,6 +36,7 @@ export const PROVIDER_LOGOS: Record = {
groq: groqLogo,
modelscope: modelscopeLogo,
ollama: ollamaLogo,
+ onnx: onnxLogo,
"tencent-coding-plan": tencentCodingPlanLogo,
"tencent-token-plan": tencentTokenPlanLogo,
"tencent-hai": tencentCodingPlanLogo,
@@ -75,6 +77,7 @@ export function getProviderLogo(providerId: string): string | undefined {
*/
export const PROVIDER_DOCS: Record = {
ollama: "https://ollama.com/search",
+ onnx: "https://onnx.ai/",
openai: "https://platform.openai.com/docs",
anthropic: "https://docs.anthropic.com",
gemini: "https://ai.google.dev/gemini-api/docs",
diff --git a/dashboard/src/assets/providers/onnx.svg b/dashboard/src/assets/providers/onnx.svg
new file mode 100644
index 00000000..8bb19eb4
--- /dev/null
+++ b/dashboard/src/assets/providers/onnx.svg
@@ -0,0 +1,4 @@
+
diff --git a/dashboard/src/components/AgentSelector.tsx b/dashboard/src/components/AgentSelector.tsx
index 2d9afd95..1ed5d1e4 100644
--- a/dashboard/src/components/AgentSelector.tsx
+++ b/dashboard/src/components/AgentSelector.tsx
@@ -1,6 +1,8 @@
import { Select, Spin } from "antd";
+import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useAgent, type OctopAgent } from "../context/AgentContext";
+import { ownedExperts } from "../utils/sharedExpert";
import { iconForName } from "../pages/Experts/components/iconForName";
import styles from "./AgentSelector.module.less";
@@ -56,6 +58,18 @@ export default function AgentSelector({
}: AgentSelectorProps) {
const { t } = useTranslation();
const { agents, activeAgentId, setActiveAgent, loading } = useAgent();
+ const selectable = useMemo(() => ownedExperts(agents), [agents]);
+
+ useEffect(() => {
+ if (loading || selectable.length === 0) return;
+ if (
+ activeAgentId &&
+ selectable.some((agent) => agent.agent_id === activeAgentId)
+ ) {
+ return;
+ }
+ setActiveAgent(selectable[0]?.agent_id ?? null);
+ }, [activeAgentId, loading, selectable, setActiveAgent]);
if (loading) {
return (
@@ -65,11 +79,11 @@ export default function AgentSelector({
);
}
- if (agents.length === 0) return null;
+ if (selectable.length === 0) return null;
- const currentId = activeAgentId ?? agents[0]?.agent_id;
+ const currentId = activeAgentId ?? selectable[0]?.agent_id;
const useBar =
- variant === "bar" || (variant === "auto" && agents.length <= 6);
+ variant === "bar" || (variant === "auto" && selectable.length <= 6);
return (
@@ -83,7 +97,7 @@ export default function AgentSelector({
role="tablist"
aria-label={t("agentSelector.label")}
>
- {agents.map((agent) => (
+ {selectable.map((agent) => (
{
+ options={selectable.map((agent) => {
const accent = agentAccent(agent);
return {
value: agent.agent_id,
@@ -116,7 +130,7 @@ export default function AgentSelector({
};
})}
optionRender={(opt) => {
- const agent = agents.find((a) => a.agent_id === opt.value);
+ const agent = selectable.find((a) => a.agent_id === opt.value);
if (!agent) return opt.label;
const accent = agentAccent(agent);
return (
diff --git a/dashboard/src/components/AuthGuard.tsx b/dashboard/src/components/AuthGuard.tsx
index b9470841..d0629cb1 100644
--- a/dashboard/src/components/AuthGuard.tsx
+++ b/dashboard/src/components/AuthGuard.tsx
@@ -2,8 +2,9 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Spin } from "antd";
import { getAuthToken } from "../api/request";
-import { authApi } from "../api/modules/auth";
+import { authApi, type OctopUser } from "../api/modules/auth";
import { applyUserLocale } from "../utils/locale";
+import { CurrentUserProvider } from "../hooks/useCurrentUser";
interface AuthGuardProps {
children: React.ReactNode;
@@ -24,6 +25,7 @@ export default function AuthGuard({ children }: AuthGuardProps) {
const hadToken = Boolean(getAuthToken());
const [checking, setChecking] = useState(!hadToken);
const [authed, setAuthed] = useState(hadToken);
+ const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
@@ -56,6 +58,7 @@ export default function AuthGuard({ children }: AuthGuardProps) {
const me = await authApi.me();
await applyUserLocale(me.locale);
if (!cancelled) {
+ setUser(me);
setAuthed(true);
setChecking(false);
}
@@ -98,5 +101,9 @@ export default function AuthGuard({ children }: AuthGuardProps) {
);
}
- return <>{children}>;
+ return (
+
+ {children}
+
+ );
}
diff --git a/dashboard/src/components/CopyableResourceId.module.less b/dashboard/src/components/CopyableResourceId.module.less
new file mode 100644
index 00000000..5eccc4b7
--- /dev/null
+++ b/dashboard/src/components/CopyableResourceId.module.less
@@ -0,0 +1,79 @@
+.root {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ max-width: 100%;
+ text-align: left;
+ color: var(--fn-text-tertiary);
+ transition: color 0.15s;
+
+ &:hover {
+ color: var(--fn-text-secondary);
+
+ .value {
+ color: var(--fn-text-primary);
+ }
+ }
+}
+
+.label {
+ flex-shrink: 0;
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ opacity: 0.75;
+}
+
+.value {
+ font-size: 11px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--fn-text-secondary);
+ transition: color 0.15s;
+}
+
+.icon {
+ flex-shrink: 0;
+ opacity: 0.5;
+}
+
+.inlineRoot {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ max-width: 100%;
+ text-align: left;
+ color: var(--fn-text-tertiary);
+ font-size: 12px;
+ line-height: 1.5715;
+ transition: color 0.15s;
+
+ &:hover {
+ color: var(--fn-text-secondary);
+ }
+}
+
+.inlineText {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.inlineIcon {
+ flex-shrink: 0;
+ opacity: 0.55;
+}
diff --git a/dashboard/src/components/CopyableResourceId.tsx b/dashboard/src/components/CopyableResourceId.tsx
new file mode 100644
index 00000000..f4bf89eb
--- /dev/null
+++ b/dashboard/src/components/CopyableResourceId.tsx
@@ -0,0 +1,57 @@
+import { useCallback } from "react";
+import { Tooltip } from "antd";
+import { Copy } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { message } from "@/utils/antdMessage";
+import styles from "./CopyableResourceId.module.less";
+
+type CopyableResourceIdProps = {
+ label: string;
+ value: string;
+ copyTitle?: string;
+ className?: string;
+ /** Match surrounding meta text (e.g. creator line). */
+ inline?: boolean;
+};
+
+export function CopyableResourceId({
+ label,
+ value,
+ copyTitle,
+ className,
+ inline = false,
+}: CopyableResourceIdProps) {
+ const { t } = useTranslation();
+
+ const copy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ message.success(t("common.copied"));
+ } catch {
+ message.error(t("common.copyFailed"));
+ }
+ }, [t, value]);
+
+ return (
+
+
+
+ );
+}
diff --git a/dashboard/src/components/EmojiPicker.module.less b/dashboard/src/components/EmojiPicker.module.less
new file mode 100644
index 00000000..80fd0f6e
--- /dev/null
+++ b/dashboard/src/components/EmojiPicker.module.less
@@ -0,0 +1,110 @@
+.overlay {
+ :global(.ant-popover-inner) {
+ padding: 0;
+ }
+}
+
+.panel {
+ width: min(320px, 72vw);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px;
+}
+
+.search {
+ flex-shrink: 0;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(8, minmax(0, 1fr));
+ gap: 4px;
+ max-height: 240px;
+ overflow: auto;
+ padding: 2px;
+}
+
+.option {
+ width: 100%;
+ aspect-ratio: 1;
+ min-height: 34px;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: var(--fn-radius-md, 8px);
+ background: transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ line-height: 1;
+ cursor: pointer;
+ transition:
+ background-color var(--fn-transition-fast, 0.15s ease),
+ border-color var(--fn-transition-fast, 0.15s ease);
+
+ &:hover {
+ background: var(--fn-bg-hover);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--fn-border-focus);
+ outline-offset: 1px;
+ }
+}
+
+.active {
+ border-color: var(--fn-text-primary);
+ background: var(--fn-color-brand-bg, rgba(79, 110, 247, 0.12));
+}
+
+.empty {
+ grid-column: 1 / -1;
+ padding: 16px 8px;
+ text-align: center;
+ font-size: 13px;
+ color: var(--fn-text-tertiary);
+}
+
+.trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 36px;
+ padding: 4px 12px 4px 8px;
+ border: 1px solid var(--fn-border-input);
+ border-radius: var(--fn-radius-md);
+ background: var(--fn-bg-primary);
+ color: var(--fn-text-primary);
+ cursor: pointer;
+ transition:
+ border-color var(--fn-transition-fast),
+ box-shadow var(--fn-transition-fast);
+
+ &:hover {
+ border-color: var(--fn-border-focus);
+ }
+
+ &:focus-visible {
+ outline: none;
+ border-color: var(--fn-border-focus);
+ box-shadow: 0 0 0 2px var(--fn-color-brand-shadow);
+ }
+}
+
+.triggerEmoji {
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ background: var(--fn-bg-secondary);
+ font-size: 18px;
+ line-height: 1;
+}
+
+.triggerHint {
+ font-size: 13px;
+ color: var(--fn-text-secondary);
+}
diff --git a/dashboard/src/components/EmojiPicker.test.tsx b/dashboard/src/components/EmojiPicker.test.tsx
new file mode 100644
index 00000000..2153ee68
--- /dev/null
+++ b/dashboard/src/components/EmojiPicker.test.tsx
@@ -0,0 +1,24 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { I18nextProvider } from "react-i18next";
+import i18n from "../i18n";
+import EmojiPicker from "./EmojiPicker";
+
+describe("EmojiPicker", () => {
+ it("opens a selectable emoji grid and reports the chosen emoji", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /emoji/i }));
+ const option = await screen.findByRole("option", { name: "⚙️" });
+ await user.click(option);
+
+ expect(onChange).toHaveBeenCalledWith("⚙️");
+ });
+});
diff --git a/dashboard/src/components/EmojiPicker.tsx b/dashboard/src/components/EmojiPicker.tsx
new file mode 100644
index 00000000..ac33ca7f
--- /dev/null
+++ b/dashboard/src/components/EmojiPicker.tsx
@@ -0,0 +1,103 @@
+import { useMemo, useState } from "react";
+import { Input, Popover } from "antd";
+import { useTranslation } from "react-i18next";
+import { SUBAGENT_EMOJI_OPTIONS } from "../utils/subagentEmojis";
+import styles from "./EmojiPicker.module.less";
+
+interface EmojiPickerProps {
+ value?: string;
+ onChange?: (emoji: string) => void;
+ /** Shown when value is empty (subagents default 🤖, skills ✨). */
+ fallback?: string;
+}
+
+/** Popover grid of catalog emojis for subagent / skill icon selection. */
+export default function EmojiPicker({
+ value,
+ onChange,
+ fallback = "🤖",
+}: EmojiPickerProps) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+
+ const options = useMemo(() => {
+ const q = query.trim();
+ if (!q) return SUBAGENT_EMOJI_OPTIONS as readonly string[];
+ // Exact / substring match so pasted or partially typed emoji still filters.
+ return SUBAGENT_EMOJI_OPTIONS.filter((emoji) => emoji.includes(q));
+ }, [query]);
+
+ const current = (value || "").trim() || fallback;
+
+ const panel = (
+
+
setQuery(e.target.value)}
+ placeholder={t("common.emojiSearchPlaceholder")}
+ aria-label={t("common.emojiSearchPlaceholder")}
+ className={styles.search}
+ />
+
+ {options.map((emoji) => {
+ const active = emoji === current;
+ return (
+
+ );
+ })}
+ {options.length === 0 ? (
+
{t("common.emojiNoResults")}
+ ) : null}
+
+
+ );
+
+ return (
+ {
+ setOpen(next);
+ if (!next) setQuery("");
+ }}
+ content={panel}
+ placement="bottomLeft"
+ arrow={false}
+ overlayClassName={styles.overlay}
+ >
+
+
+ );
+}
diff --git a/dashboard/src/components/ExpertColorPicker.tsx b/dashboard/src/components/ExpertColorPicker.tsx
new file mode 100644
index 00000000..fbdcfbbc
--- /dev/null
+++ b/dashboard/src/components/ExpertColorPicker.tsx
@@ -0,0 +1,47 @@
+import { Tooltip } from "antd";
+import { useTranslation } from "react-i18next";
+import {
+ PALETTE_SWATCH,
+ VALID_PALETTES,
+ type ThemePalette,
+} from "../styles/themePalettes";
+import styles from "./PaletteSwitcher.module.less";
+
+interface ExpertColorPickerProps {
+ value: ThemePalette;
+ onChange: (palette: ThemePalette) => void;
+}
+
+/** Curated 8-swatch picker for expert/agent accent color (list cards). */
+export default function ExpertColorPicker({
+ value,
+ onChange,
+}: ExpertColorPickerProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {VALID_PALETTES.map((key) => {
+ const active = value === key;
+ const label = t(`header.palette.${key}`);
+ return (
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/dashboard/src/components/ForbiddenPage.tsx b/dashboard/src/components/ForbiddenPage.tsx
new file mode 100644
index 00000000..e3aef256
--- /dev/null
+++ b/dashboard/src/components/ForbiddenPage.tsx
@@ -0,0 +1,33 @@
+import { Button, Result } from "antd";
+import { ShieldOff } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+/** Full-area "no permission" placeholder used by route and tab guards. */
+export default function ForbiddenPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ return (
+
+ }
+ title={t("common.noPermission")}
+ subTitle={t("common.noPermissionHint")}
+ extra={
+
+ }
+ />
+
+ );
+}
diff --git a/dashboard/src/components/RequireAdmin.tsx b/dashboard/src/components/RequireAdmin.tsx
deleted file mode 100644
index 6eeeefe8..00000000
--- a/dashboard/src/components/RequireAdmin.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Spin } from "antd";
-import { Navigate } from "react-router-dom";
-import { useUserRole } from "../hooks/useUserRole";
-
-interface Props {
- children: React.ReactNode;
-}
-
-/**
- * Wraps admin-only routes. Redirects to /chat for non-admins.
- * Shows a full-screen spinner while the role is loading to avoid
- * a flash of the admin page before the redirect fires.
- */
-export default function RequireAdmin({ children }: Props) {
- const role = useUserRole();
-
- if (role === null) {
- // Loading — show spinner.
- return (
-
-
-
- );
- }
-
- if (role !== "admin") {
- return ;
- }
-
- return <>{children}>;
-}
diff --git a/dashboard/src/components/RequirePermission.tsx b/dashboard/src/components/RequirePermission.tsx
new file mode 100644
index 00000000..74520156
--- /dev/null
+++ b/dashboard/src/components/RequirePermission.tsx
@@ -0,0 +1,40 @@
+import { Spin } from "antd";
+import { useLocation } from "react-router-dom";
+import { useCurrentUser } from "../hooks/useCurrentUser";
+import { canAccessPath } from "../utils/permissions";
+import ForbiddenPage from "./ForbiddenPage";
+
+interface Props {
+ children: React.ReactNode;
+}
+
+/**
+ * Wraps routes that have a module permission (or admin-only paths).
+ * Shows a spinner while /auth/me is loading, and a permission-denied
+ * page when the current user cannot access the path.
+ */
+export default function RequirePermission({ children }: Props) {
+ const user = useCurrentUser();
+ const location = useLocation();
+
+ if (user === null) {
+ return (
+
+
+
+ );
+ }
+
+ if (!canAccessPath(user, location.pathname)) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.module.less b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.module.less
index f24d2ea8..41d2bec0 100644
--- a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.module.less
+++ b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.module.less
@@ -23,6 +23,10 @@
text-align: center;
}
+.wide .card {
+ width: min(100%, 480px);
+}
+
.icon {
display: flex;
align-items: center;
diff --git a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.tsx b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.tsx
index f8f7cdfa..4b0207c4 100644
--- a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.tsx
+++ b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.tsx
@@ -28,6 +28,9 @@ interface StreamSetupGuideProps {
secondaryAction?: SetupGuideAction;
/** Optional third action (e.g. uninstall) rendered after the main pair. */
extraAction?: SetupGuideAction;
+ className?: string;
+ /** Widen the card for longer explanatory copy. */
+ wide?: boolean;
}
function ActionButton({ action }: { action: SetupGuideAction }) {
@@ -55,11 +58,16 @@ export default function StreamSetupGuide({
primaryAction,
secondaryAction,
extraAction,
+ className,
+ wide,
}: StreamSetupGuideProps) {
const hasActions = primaryAction || secondaryAction || extraAction;
+ const wrapClass = [styles.wrap, wide ? styles.wide : "", className ?? ""]
+ .filter(Boolean)
+ .join(" ");
return (
-
+
{icon}
{title}
diff --git a/dashboard/src/context/AgentContext.tsx b/dashboard/src/context/AgentContext.tsx
index 71c47b89..c1439bb5 100644
--- a/dashboard/src/context/AgentContext.tsx
+++ b/dashboard/src/context/AgentContext.tsx
@@ -29,6 +29,10 @@ export interface OctopAgent {
user_id?: number | null;
/** Resolved username for admin list view. */
owner_username?: string | null;
+ /** Whether the owner has shared this expert with other users. */
+ is_shared?: boolean;
+ /** Whether the current user owns this expert. */
+ is_owner?: boolean;
name: string;
description: string | null;
persona_mbti: string | null;
diff --git a/dashboard/src/hooks/useCurrentUser.tsx b/dashboard/src/hooks/useCurrentUser.tsx
new file mode 100644
index 00000000..288b681d
--- /dev/null
+++ b/dashboard/src/hooks/useCurrentUser.tsx
@@ -0,0 +1,51 @@
+import {
+ createContext,
+ useContext,
+ useMemo,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
+import type { OctopUser } from "../api/modules/auth";
+
+type CurrentUserContextValue = {
+ user: OctopUser | null;
+ setUser: Dispatch
>;
+};
+
+const CurrentUserContext = createContext(null);
+
+export function CurrentUserProvider({
+ user,
+ setUser,
+ children,
+}: {
+ user: OctopUser | null;
+ setUser: Dispatch>;
+ children: ReactNode;
+}) {
+ const value = useMemo(() => ({ user, setUser }), [user, setUser]);
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Current authenticated user from AuthGuard's ``/auth/me`` result.
+ * ``null`` while loading or when the provider is not mounted.
+ */
+export function useCurrentUser(): OctopUser | null {
+ return useContext(CurrentUserContext)?.user ?? null;
+}
+
+export function useSetCurrentUser(): Dispatch<
+ SetStateAction
+> {
+ const ctx = useContext(CurrentUserContext);
+ if (!ctx) {
+ return () => undefined;
+ }
+ return ctx.setUser;
+}
diff --git a/dashboard/src/hooks/useGatedSearchTabs.ts b/dashboard/src/hooks/useGatedSearchTabs.ts
new file mode 100644
index 00000000..3cd6aa13
--- /dev/null
+++ b/dashboard/src/hooks/useGatedSearchTabs.ts
@@ -0,0 +1,82 @@
+import { useEffect, useMemo } from "react";
+import { useSearchParams } from "react-router-dom";
+import { useCurrentUser } from "./useCurrentUser";
+import { userCanKey } from "../utils/permissions";
+
+/**
+ * Search-param tabs filtered by module permission.
+ * Unknown / missing ``?tab=`` redirects to the first allowed tab.
+ * An explicit disallowed tab stays and ``forbidden`` is true.
+ */
+export function useGatedSearchTabs({
+ tabs,
+ tabPermissions,
+ parseTab,
+ querylessKey,
+}: {
+ tabs: readonly TTab[];
+ tabPermissions: Record;
+ parseTab: (raw: string | null) => T;
+ querylessKey: T;
+}) {
+ const user = useCurrentUser();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const raw = searchParams.get("tab");
+ const requested = parseTab(raw);
+ const explicitKnown = raw !== null && tabs.some((tab) => tab.key === raw);
+
+ const allowedTabs = useMemo(
+ () =>
+ user
+ ? tabs.filter((tab) => userCanKey(user, tabPermissions[tab.key]))
+ : [],
+ [tabs, user, tabPermissions],
+ );
+
+ const requestedAllowed = allowedTabs.some((tab) => tab.key === requested);
+ const forbidden = Boolean(user && explicitKnown && !requestedAllowed);
+
+ useEffect(() => {
+ if (!user || forbidden || requestedAllowed) return;
+ const first = allowedTabs[0];
+ if (!first) return;
+ const next = new URLSearchParams(searchParams);
+ if (first.key === querylessKey) {
+ if (!next.has("tab")) return;
+ next.delete("tab");
+ } else {
+ next.set("tab", first.key);
+ }
+ setSearchParams(next, { replace: true });
+ }, [
+ user,
+ forbidden,
+ requestedAllowed,
+ allowedTabs,
+ querylessKey,
+ searchParams,
+ setSearchParams,
+ ]);
+
+ const selectTab = (key: T) => {
+ const next = new URLSearchParams(searchParams);
+ if (key === querylessKey) {
+ next.delete("tab");
+ } else {
+ next.set("tab", key);
+ }
+ setSearchParams(next, { replace: true });
+ };
+
+ const activeTab: T = requestedAllowed
+ ? requested
+ : allowedTabs[0]?.key ?? requested;
+
+ return {
+ user,
+ allowedTabs,
+ activeTab,
+ forbidden: forbidden || Boolean(user && allowedTabs.length === 0),
+ selectTab,
+ };
+}
diff --git a/dashboard/src/hooks/useListPanelCollapsed.test.ts b/dashboard/src/hooks/useListPanelCollapsed.test.ts
new file mode 100644
index 00000000..0bf4c255
--- /dev/null
+++ b/dashboard/src/hooks/useListPanelCollapsed.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it, beforeEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useListPanelCollapsed } from "./useListPanelCollapsed";
+
+describe("useListPanelCollapsed", () => {
+ const key = "octop:test-list-collapsed";
+
+ beforeEach(() => {
+ localStorage.removeItem(key);
+ });
+
+ it("defaults to expanded when no storage entry", () => {
+ const { result } = renderHook(() => useListPanelCollapsed(key));
+ expect(result.current.collapsed).toBe(false);
+ });
+
+ it("defaults to collapsed when defaultCollapsed is true", () => {
+ const { result } = renderHook(() =>
+ useListPanelCollapsed(key, { defaultCollapsed: true }),
+ );
+ expect(result.current.collapsed).toBe(true);
+ });
+
+ it("persists toggle state", () => {
+ const { result } = renderHook(() => useListPanelCollapsed(key));
+ act(() => result.current.toggle());
+ expect(result.current.collapsed).toBe(true);
+ expect(localStorage.getItem(key)).toBe("1");
+ act(() => result.current.toggle());
+ expect(result.current.collapsed).toBe(false);
+ expect(localStorage.getItem(key)).toBe("0");
+ });
+});
diff --git a/dashboard/src/hooks/useListPanelCollapsed.ts b/dashboard/src/hooks/useListPanelCollapsed.ts
new file mode 100644
index 00000000..b3571b3a
--- /dev/null
+++ b/dashboard/src/hooks/useListPanelCollapsed.ts
@@ -0,0 +1,39 @@
+import { useCallback, useState } from "react";
+
+function loadCollapsed(storageKey: string, defaultCollapsed: boolean): boolean {
+ try {
+ const stored = localStorage.getItem(storageKey);
+ if (stored === null) return defaultCollapsed;
+ return stored === "1";
+ } catch {
+ return defaultCollapsed;
+ }
+}
+
+function saveCollapsed(storageKey: string, collapsed: boolean) {
+ try {
+ localStorage.setItem(storageKey, collapsed ? "1" : "0");
+ } catch {
+ /* ignore */
+ }
+}
+
+export function useListPanelCollapsed(
+ storageKey: string,
+ options?: { defaultCollapsed?: boolean },
+) {
+ const defaultCollapsed = options?.defaultCollapsed ?? false;
+ const [collapsed, setCollapsed] = useState(() =>
+ loadCollapsed(storageKey, defaultCollapsed),
+ );
+
+ const toggle = useCallback(() => {
+ setCollapsed((prev) => {
+ const next = !prev;
+ saveCollapsed(storageKey, next);
+ return next;
+ });
+ }, [storageKey]);
+
+ return { collapsed, toggle, setCollapsed };
+}
diff --git a/dashboard/src/hooks/usePathTabs.ts b/dashboard/src/hooks/usePathTabs.ts
index 85d8102c..d71c3a1c 100644
--- a/dashboard/src/hooks/usePathTabs.ts
+++ b/dashboard/src/hooks/usePathTabs.ts
@@ -7,6 +7,8 @@ export interface UsePathTabsOptions {
tabs: readonly T[];
storageKey: string;
defaultTab: T;
+ /** When set, saved/bare fallback only uses allowed tabs. Disallowed URLs stay. */
+ isAllowed?: (tab: T) => boolean;
}
export interface UsePathTabsResult {
@@ -29,6 +31,7 @@ export function usePathTabs({
tabs,
storageKey,
defaultTab,
+ isAllowed,
}: UsePathTabsOptions): UsePathTabsResult {
const location = useLocation();
const navigate = useNavigate();
@@ -54,12 +57,12 @@ export function usePathTabs({
const readSaved = useCallback((): T => {
try {
const saved = localStorage.getItem(storageKey);
- if (isTab(saved)) return saved;
+ if (isTab(saved) && (isAllowed?.(saved) ?? true)) return saved;
} catch {
/* ignore */
}
return defaultTab;
- }, [storageKey, isTab, defaultTab]);
+ }, [storageKey, isTab, defaultTab, isAllowed]);
const pathTab = tabFromPath(location.pathname);
const underBase =
diff --git a/dashboard/src/hooks/useUpdateStatus.test.ts b/dashboard/src/hooks/useUpdateStatus.test.ts
new file mode 100644
index 00000000..2963481f
--- /dev/null
+++ b/dashboard/src/hooks/useUpdateStatus.test.ts
@@ -0,0 +1,102 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+
+import type { UpdateStatus } from "../api/modules/update";
+import {
+ UPDATE_STATUS_CHANGED_EVENT,
+ UPDATE_STATUS_POLL_MS,
+ clearStoredUpdateStatus,
+ storeUpdateStatus,
+} from "../utils/updateStatusCache";
+
+const getUpdateStatus = vi.fn();
+
+vi.mock("../api/modules/update", () => ({
+ updateApi: {
+ getUpdateStatus: (...args: unknown[]) => getUpdateStatus(...args),
+ },
+}));
+
+import { useUpdateStatus } from "./useUpdateStatus";
+
+const sample: UpdateStatus = {
+ current_version: "0.9.6",
+ latest_version: "0.9.7",
+ has_update: true,
+ is_editable: false,
+ service_mode: null,
+ error: null,
+ last_check_time: "2026-07-14T00:00:00Z",
+ release_notes: null,
+};
+
+describe("useUpdateStatus", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ getUpdateStatus.mockReset();
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-07-14T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ clearStoredUpdateStatus();
+ localStorage.clear();
+ });
+
+ it("probes on mount when cache is empty", async () => {
+ getUpdateStatus.mockResolvedValue(sample);
+ const { result } = renderHook(() => useUpdateStatus());
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(getUpdateStatus).toHaveBeenCalledTimes(1);
+ expect(result.current.hasUpdate).toBe(true);
+ expect(result.current.status?.latest_version).toBe("0.9.7");
+ });
+
+ it("re-probes after TTL via the poll interval", async () => {
+ getUpdateStatus.mockResolvedValue(sample);
+ renderHook(() => useUpdateStatus());
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(getUpdateStatus).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ // One poll tick lands at TTL; cache is expired so the probe runs again.
+ vi.advanceTimersByTime(UPDATE_STATUS_POLL_MS);
+ await Promise.resolve();
+ });
+
+ expect(getUpdateStatus.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("picks up status written by another screen", async () => {
+ getUpdateStatus.mockResolvedValue({
+ ...sample,
+ has_update: false,
+ latest_version: "0.9.6",
+ });
+ const { result } = renderHook(() => useUpdateStatus());
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(result.current.hasUpdate).toBe(false);
+
+ await act(async () => {
+ storeUpdateStatus(sample);
+ });
+
+ expect(result.current.hasUpdate).toBe(true);
+ expect(result.current.status?.latest_version).toBe("0.9.7");
+ });
+
+ it("listens for the shared change event name", () => {
+ expect(UPDATE_STATUS_CHANGED_EVENT).toBe("octop:update-status-changed");
+ });
+});
diff --git a/dashboard/src/hooks/useUpdateStatus.ts b/dashboard/src/hooks/useUpdateStatus.ts
index bc596f59..76e8fb40 100644
--- a/dashboard/src/hooks/useUpdateStatus.ts
+++ b/dashboard/src/hooks/useUpdateStatus.ts
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { updateApi, type UpdateStatus } from "../api/modules/update";
import {
+ UPDATE_STATUS_CHANGED_EVENT,
+ UPDATE_STATUS_POLL_MS,
isUpdateStatusCacheExpired,
readStoredUpdateStatus,
storeUpdateStatus,
@@ -55,6 +57,25 @@ export function useUpdateStatus() {
return () => window.removeEventListener("focus", onFocus);
}, [refreshStatus]);
+ // Keep checking while the dashboard stays open (cache TTL still gates PyPI).
+ useEffect(() => {
+ const id = window.setInterval(() => {
+ void refreshStatus(false);
+ }, UPDATE_STATUS_POLL_MS);
+ return () => window.clearInterval(id);
+ }, [refreshStatus]);
+
+ // Sync Header / Sidebar / Update page when any writer stores a new status.
+ useEffect(() => {
+ const onChanged = (event: Event) => {
+ const next = (event as CustomEvent).detail;
+ if (next) setStatus(next);
+ };
+ window.addEventListener(UPDATE_STATUS_CHANGED_EVENT, onChanged);
+ return () =>
+ window.removeEventListener(UPDATE_STATUS_CHANGED_EVENT, onChanged);
+ }, []);
+
const hasUpdate = Boolean(status?.has_update && status?.latest_version);
return { status, hasUpdate, refreshStatus };
diff --git a/dashboard/src/hooks/useUserRole.ts b/dashboard/src/hooks/useUserRole.ts
index 036a1da2..86afa05d 100644
--- a/dashboard/src/hooks/useUserRole.ts
+++ b/dashboard/src/hooks/useUserRole.ts
@@ -1,5 +1,4 @@
-import { useEffect, useState } from "react";
-import { authApi } from "../api/modules/auth";
+import { useCurrentUser } from "./useCurrentUser";
/**
* Fetch the current user's role once on mount.
@@ -7,20 +6,6 @@ import { authApi } from "../api/modules/auth";
* callers should treat null as "not admin" to avoid info leaks.
*/
export function useUserRole(): "admin" | "user" | null {
- const [role, setRole] = useState<"admin" | "user" | null>(null);
- useEffect(() => {
- let cancelled = false;
- authApi
- .me()
- .then((u) => {
- if (!cancelled) setRole(u.role);
- })
- .catch(() => {
- // Non-200 (unauthenticated probe etc.) — leave role as null.
- });
- return () => {
- cancelled = true;
- };
- }, []);
- return role;
+ const user = useCurrentUser();
+ return user?.role ?? null;
}
diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx
index 6b501d16..6172ddd2 100644
--- a/dashboard/src/layouts/MainLayout/index.tsx
+++ b/dashboard/src/layouts/MainLayout/index.tsx
@@ -14,13 +14,13 @@ import {
MOBILE_FULLSCREEN_PATHS,
SELF_HEADER_PATHS,
isWorkbenchPath,
- isControlAdminPath,
} from "../../routes";
import { CHAT_HISTORY_RAIL_ID, isChatPath } from "../chatHistoryRail";
import { useIsMobile } from "../../hooks/useIsMobile";
import { useChatSidebarOpen } from "../../pages/Chat/hooks/useChatSidebarState";
import { EXPAND_CHAT_RAIL_EVENT } from "../../pages/Chat/components/ChatSidebarPanel";
-import RequireAdmin from "../../components/RequireAdmin";
+import RequirePermission from "../../components/RequirePermission";
+import { routeNeedsPermission } from "../../utils/permissions";
const Chat = lazy(() => import("../../pages/Chat"));
const WorkbenchPage = lazy(() => import("../../pages/Control/Workbench"));
@@ -159,8 +159,8 @@ export default function MainLayout() {
{routeConfigs.map((rc) => {
let el = rc.useWrapper ? : rc.element;
- if (rc.path.startsWith("/admin/") || isControlAdminPath(rc.path)) {
- el = {el};
+ if (!rc.useWrapper && routeNeedsPermission(rc.path)) {
+ el = {el};
}
return ;
})}
@@ -295,7 +295,7 @@ export default function MainLayout() {
flexDirection: "column",
}}
>
-
+
-
+
)}
diff --git a/dashboard/src/layouts/Sidebar.tsx b/dashboard/src/layouts/Sidebar.tsx
index c8bd52ac..a756a894 100644
--- a/dashboard/src/layouts/Sidebar.tsx
+++ b/dashboard/src/layouts/Sidebar.tsx
@@ -16,6 +16,7 @@ import {
Waypoints,
Link2,
Database,
+ Cpu,
Users as UsersIcon,
Activity,
Share2,
@@ -30,9 +31,10 @@ import {
} from "lucide-react";
import { useTheme } from "../context/ThemeContext";
import { useUserRole } from "../hooks/useUserRole";
+import { useCurrentUser, useSetCurrentUser } from "../hooks/useCurrentUser";
import { useUpdateStatus } from "../hooks/useUpdateStatus";
-import { authApi } from "../api/modules/auth";
import type { OctopUser } from "../api/modules/auth";
+import { navAllowed } from "../utils/permissions";
import { prefetchRoute } from "../routes/prefetch";
import { useChatSidebarOpen } from "../pages/Chat/hooks/useChatSidebarState";
import { EXPAND_CHAT_RAIL_EVENT } from "../pages/Chat/components/ChatSidebarPanel";
@@ -124,7 +126,7 @@ interface NavSection {
const iconSize = 16;
const iconStroke = 1.8;
-function buildNavSections(role: "admin" | "user" | null): NavSection[] {
+function buildNavSections(user: OctopUser | null): NavSection[] {
const sections: NavSection[] = [
{
items: [
@@ -154,103 +156,135 @@ function buildNavSections(role: "admin" | "user" | null): NavSection[] {
},
],
},
+ ];
+
+ const settingsItems: NavItem[] = [
{
- groupKey: "nav.settings",
- items: [
- {
- key: "personalization",
- path: "/personalization/skills",
- icon:
,
- labelKey: "nav.personalization",
- },
- {
- key: "channels",
- path: "/personalization/channels",
- icon:
,
- labelKey: "nav.channels",
- },
- {
- key: "connectors",
- path: "/connectors",
- icon:
,
- labelKey: "nav.connectors",
- },
- {
- key: "skill-packages",
- path: "/skill-packages",
- icon:
,
- labelKey: "nav.skillPackages",
- },
- ],
+ key: "personalization",
+ path: "/personalization/skills",
+ icon:
,
+ labelKey: "nav.personalization",
},
];
+ if (navAllowed(user, "channels")) {
+ settingsItems.push({
+ key: "channels",
+ path: "/personalization/channels",
+ icon:
,
+ labelKey: "nav.channels",
+ });
+ }
+ if (navAllowed(user, "connectors")) {
+ settingsItems.push({
+ key: "connectors",
+ path: "/connectors",
+ icon:
,
+ labelKey: "nav.connectors",
+ });
+ }
+ if (navAllowed(user, "skill-packages")) {
+ settingsItems.push({
+ key: "skill-packages",
+ path: "/skill-packages",
+ icon:
,
+ labelKey: "nav.skillPackages",
+ });
+ }
+ if (navAllowed(user, "knowledge-bases")) {
+ settingsItems.push({
+ key: "knowledge-bases",
+ path: "/knowledge-bases",
+ icon:
,
+ labelKey: "nav.knowledgeBases",
+ badge: "BETA",
+ });
+ }
+ if (settingsItems.length > 0) {
+ sections.push({ groupKey: "nav.settings", items: settingsItems });
+ }
- if (role === "admin") {
- sections.push({
- groupKey: "nav.control",
- items: [
- {
- key: "workbench",
- path: "/workbench",
- icon:
,
- labelKey: "nav.workbench",
- },
- {
- key: "remote-desktop",
- path: "/remote-desktop",
- icon:
,
- labelKey: "nav.remoteDesktop",
- },
- {
- key: "acp",
- path: "/acp",
- icon:
,
- labelKey: "nav.acp",
- },
- ],
+ const controlItems: NavItem[] = [];
+ if (navAllowed(user, "workbench")) {
+ controlItems.push({
+ key: "workbench",
+ path: "/workbench",
+ icon:
,
+ labelKey: "nav.workbench",
});
- sections.push({
- groupKey: "nav.admin",
- items: [
- {
- key: "admin-users",
- path: "/admin/users",
- icon:
,
- labelKey: "nav.adminUsers",
- },
- {
- key: "models",
- path: "/admin/models",
- icon:
,
- labelKey: "nav.models",
- },
- {
- key: "admin-storage",
- path: "/admin/backend",
- icon:
,
- labelKey: "nav.adminStorage",
- },
- {
- key: "admin-plugins",
- path: "/admin/plugins",
- icon:
,
- labelKey: "nav.adminPlugins",
- },
- {
- key: "admin-security",
- path: "/admin/security",
- icon:
,
- labelKey: "nav.security",
- },
- {
- key: "admin-advanced",
- path: "/admin/advanced",
- icon:
,
- labelKey: "nav.adminAdvanced",
- },
- ],
+ }
+ if (navAllowed(user, "remote-desktop")) {
+ controlItems.push({
+ key: "remote-desktop",
+ path: "/remote-desktop",
+ icon:
,
+ labelKey: "nav.remoteDesktop",
+ });
+ }
+ // ACP: no module key this round — admin role only.
+ if (navAllowed(user, "acp")) {
+ controlItems.push({
+ key: "acp",
+ path: "/acp",
+ icon:
,
+ labelKey: "nav.acp",
+ });
+ }
+ if (controlItems.length > 0) {
+ sections.push({ groupKey: "nav.control", items: controlItems });
+ }
+
+ const adminItems: NavItem[] = [];
+ if (navAllowed(user, "admin-users")) {
+ adminItems.push({
+ key: "admin-users",
+ path: "/admin/users",
+ icon:
,
+ labelKey: "nav.adminUsers",
+ });
+ }
+ if (navAllowed(user, "models")) {
+ adminItems.push({
+ key: "models",
+ path: "/admin/models",
+ icon:
,
+ labelKey: "nav.models",
+ });
+ }
+ if (navAllowed(user, "admin-storage")) {
+ adminItems.push({
+ key: "admin-storage",
+ path: "/admin/backend",
+ icon:
,
+ labelKey: "nav.adminStorage",
+ });
+ }
+ if (navAllowed(user, "admin-plugins")) {
+ adminItems.push({
+ key: "admin-plugins",
+ path: "/admin/plugins",
+ icon:
,
+ labelKey: "nav.adminPlugins",
});
}
+ if (navAllowed(user, "admin-security")) {
+ adminItems.push({
+ key: "admin-security",
+ path: "/admin/security",
+ icon:
,
+ labelKey: "nav.security",
+ });
+ }
+ if (navAllowed(user, "admin-advanced")) {
+ adminItems.push({
+ key: "admin-advanced",
+ path: "/admin/advanced",
+ icon:
,
+ labelKey: "nav.adminAdvanced",
+ });
+ }
+ if (adminItems.length > 0) {
+ sections.push({ groupKey: "nav.admin", items: adminItems });
+ }
return sections;
}
@@ -405,8 +439,9 @@ function NavList({
}) {
const { t } = useTranslation();
const role = useUserRole();
+ const user = useCurrentUser();
const { hasUpdate } = useUpdateStatus();
- const navSections = buildNavSections(role);
+ const navSections = buildNavSections(user);
const MOBILE_HIDDEN_KEYS = new Set
();
@@ -503,23 +538,17 @@ export default function Sidebar({
const { t } = useTranslation();
const { isDark } = useTheme();
const role = useUserRole();
+ const user = useCurrentUser();
+ const setUser = useSetCurrentUser();
const { hasUpdate } = useUpdateStatus();
- const navSections = buildNavSections(role);
+ const navSections = buildNavSections(user);
const { toggleGroup, isGroupCollapsed } = useNavGroupCollapse(
navSections,
selectedKey,
);
- const [user, setUser] = useState(null);
const [chatSidebarOpen, setChatSidebarOpen] = useChatSidebarOpen();
const showChatRailExpand = !chatSidebarOpen;
- useEffect(() => {
- authApi
- .me()
- .then(setUser)
- .catch(() => {});
- }, []);
-
const isRailCollapsed = collapsed && !isMobile;
const wordmarkSrc = isDark ? "/logo_name_dark.png" : "/logo_name.png";
diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json
index d7b0f2d6..be84f34d 100644
--- a/dashboard/src/locales/en.json
+++ b/dashboard/src/locales/en.json
@@ -18,6 +18,7 @@
"view": "View",
"create": "Create",
"add": "Add",
+ "actions": "Actions",
"upload": "Upload",
"download": "Download",
"refresh": "Refresh",
@@ -37,8 +38,15 @@
"askOctopHint": "If the install keeps failing, copy the log and ask Octop to help you troubleshoot.",
"close": "Close",
"contentPlaceholder": "Enter content...",
+ "emojiPick": "Choose emoji",
+ "emojiPickerLabel": "Emoji",
+ "emojiSearchPlaceholder": "Filter emoji",
+ "emojiNoResults": "No matching emoji",
"gotIt": "Got it",
"unknownError": "Unknown error",
+ "noPermission": "Permission denied",
+ "noPermissionHint": "You do not have access to this page. Ask an administrator to grant it.",
+ "backToChat": "Back to chat",
"viewDetail": "View Details",
"viewMore": "View More",
"deleteFailed": "Delete failed",
@@ -61,6 +69,7 @@
"SETUP_REQUIRED": "Initial setup is required.",
"DATABASE_NOT_EMPTY": "The target database already has users. Use an empty database, or log in with the existing admin.",
"BACKUP_DRIVER_MISMATCH": "This backup was made with a different database engine (SQLite vs PostgreSQL). Switch the runtime to match the backup, or create a new backup on the current engine. Cross-engine restore is not supported.",
+ "BACKUP_IN_PROGRESS": "A backup is already in progress. Try again shortly.",
"FORBIDDEN": "Permission denied.",
"NOT_FOUND": "Not found.",
"USER_DISABLED": "This account has been disabled.",
@@ -79,6 +88,7 @@
"PROVIDER_NAME_TAKEN": "Provider name is already in use.",
"PROVIDER_NOT_VISIBLE": "Provider is not visible.",
"PROVIDER_REFERENCED": "Provider is in use and cannot be removed.",
+ "PROVIDER_LOCAL_PROTECTED": "Local runtime providers cannot be deleted.",
"STORAGE_BACKEND_NAME_TAKEN": "Storage backend name is already in use.",
"STORAGE_BACKEND_REFERENCED": "Storage backend is in use and cannot be removed.",
"CHANNEL_KIND_UNSUPPORTED": "Unsupported channel type.",
@@ -109,13 +119,25 @@
"SKILL_PACKAGE_NOT_FOUND": "Skill package not found.",
"SKILL_PACKAGE_NAME_TAKEN": "Skill package {{name}} already exists.",
"SKILL_PACKAGE_BACKEND_UNSUPPORTED": "Skill packages currently support only “Full local environment (filesystem + shell)” or “Local filesystem (no shell commands)”, with storage root / (POSIX) or the agent workspace root (Windows default).",
+ "PUBLISHED_EXPERT_SLUG_TAKEN": "Published expert slug {{slug}} already exists.",
+ "PUBLISHED_EXPERT_ALREADY_EXISTS": "This agent is already published as “{{name}}”. Refresh the published template instead.",
+ "OIDC_BAD_REQUEST": "Invalid OIDC request: {{detail}}",
"EXPERT_MARKET_FAILED": "Expert market is temporarily unavailable. Please try again later.",
"SKILLHUB_SSL_FAILED": "SkillHub request failed: Python SSL error detected — on macOS try `brew reinstall openssl@3`, or check your system CA certificates.",
"DESKTOP_SESSION_LIMIT": "Too many concurrent remote desktop streams (max {{limit}}). Close other connections and try again.",
"DESKTOP_CAPTURE_FAILED": "Screen capture failed repeatedly. On Linux servers restart the virtual desktop; on macOS grant Screen Recording and Accessibility, then reconnect.",
"PLUGIN_INVALID_ARCHIVE": "The download is not a valid plugin ZIP. Use a direct .zip download URL (on GitHub use raw.githubusercontent.com or the “Download” / “raw” link, not the /blob/ page).",
"PLUGIN_INSTALL_FAILED": "Failed to download or install the plugin: {{reason}}",
- "PLUGIN_ALREADY_EXISTS": "Plugin {{id}} is already installed."
+ "PLUGIN_ALREADY_EXISTS": "Plugin {{id}} is already installed.",
+ "KNOWLEDGE_FEATURE_DISABLED": "Knowledge bases are disabled by the administrator.",
+ "KNOWLEDGE_PREREQUISITES_FAILED": "Knowledge-base embedding prerequisites are not ready.",
+ "KNOWLEDGE_NOT_FOUND": "Knowledge base or document not found.",
+ "KNOWLEDGE_FORBIDDEN": "You do not have access to this knowledge base.",
+ "KNOWLEDGE_DOC_LIMIT": "A knowledge base can contain at most 100 documents.",
+ "KNOWLEDGE_DOC_TOO_LARGE": "This document exceeds the 20 MB size limit.",
+ "KNOWLEDGE_BASE_LIMIT": "You can create at most 20 knowledge bases.",
+ "KNOWLEDGE_UNSUPPORTED_TYPE": "This document type is not supported for knowledge bases.",
+ "KNOWLEDGE_NAME_TAKEN": "You already have a knowledge base with this name."
},
"nav": {
"chat": "Chat",
@@ -162,13 +184,15 @@
"newVersionBadge": "New version",
"admin": "Admin",
"adminUsers": "Users",
+ "adminSso": "Single sign-on",
"adminStorage": "Storage",
"adminAudit": "Audit log",
"adminAgents": "Agents",
"adminAdvanced": "Application Settings",
"security": "Security",
"adminPlugins": "Plugins",
- "skillPackages": "Skill Packages"
+ "skillPackages": "Skill Packages",
+ "knowledgeBases": "Knowledge Bases"
},
"skillPackages": {
"title": "Skill Packages",
@@ -189,6 +213,14 @@
"fromSkillHubHint": "Import a SkillHub skillset as a new skill package.",
"addAsPackage": "Add as package",
"empty": "No skill packages yet",
+ "emptyGuideTitle": "No skill packages yet",
+ "emptyGuideDesc": "A skill package groups reusable skills so you can mount them on experts and use them in chat.",
+ "emptyGuideStepWhat": "What is a skill?",
+ "emptyGuideStepWhatDetail": "A skill is a playbook (SKILL.md) that tells an expert how to complete a task, such as research, filling forms, or calling tools.",
+ "emptyGuideStepHow": "How to use it",
+ "emptyGuideStepHowDetail": "Create a package, add skills or import them from the market, then mount the package on an expert. Those skills will be available in chat.",
+ "emptyGuideStepShare": "What sharing does",
+ "emptyGuideStepShareDetail": "Packages are visible to signed-in users on this instance. Others can mount them; only the creator can edit or delete.",
"selectPackage": "Select a skill package to view its skills",
"backToList": "Back to list",
"noDescription": "No description",
@@ -213,7 +245,157 @@
"skillSaved": "Skill saved",
"skillDeleted": "Skill deleted",
"resizeSidebar": "Resize package list",
- "createdBy": "Created by {{name}}"
+ "createdBy": "Created by {{name}}",
+ "packageId": "Package ID",
+ "copyPackageId": "Click to copy package ID",
+ "collapseListPanel": "Collapse list",
+ "expandListPanel": "Expand list"
+ },
+ "knowledgeBases": {
+ "title": "Knowledge Bases",
+ "subtitle": "Index documents for chat retrieval. Optionally share them with users on this instance.",
+ "featureTitle": "Enable Knowledge Bases",
+ "settingsTitle": "Knowledge base settings",
+ "settingsLead": "Instance-wide switch. When on, configure an embedding model. When off, knowledge bases are unavailable for everyone.",
+ "settingsOpen": "Enable knowledge bases",
+ "manageModels": "Manage models",
+ "unavailableTitle": "Knowledge bases are not ready",
+ "unavailableDescription": "Open Settings in the top-right to enable knowledge bases and configure an embedding model.",
+ "unavailableDescriptionNonAdmin": "Knowledge bases are not enabled or not configured yet. Please contact an administrator.",
+ "create": "Create knowledge base",
+ "edit": "Edit knowledge base",
+ "name": "Name",
+ "nameRequired": "Please enter a knowledge base name",
+ "description": "Description",
+ "empty": "No knowledge bases yet",
+ "emptyGuideTitle": "No knowledge bases yet",
+ "emptyGuideDesc": "Turn documents into a searchable library for chat citations, and optionally share them with users on this instance.",
+ "emptyGuideStepWhat": "What is a knowledge base?",
+ "emptyGuideStepWhatDetail": "A knowledge base splits and embeds uploaded documents so experts can retrieve relevant passages in chat, instead of stuffing the full text into context.",
+ "emptyGuideStepHow": "How to use it",
+ "emptyGuideStepHowDetail": "Create a knowledge base and upload md / txt / pdf / docx / pptx files. Turn on Default enabled, or select it manually in chat.",
+ "emptyGuideStepShare": "What sharing does",
+ "emptyGuideStepShareDetail": "When shared, everyone signed in on this instance can read and select it; only the owner can edit. Default enabled applies only to the owner.",
+ "enableGuideTitle": "How do I enable knowledge bases?",
+ "enableGuideDesc": "Knowledge bases are instance-wide. Turn the feature on, configure an embedding model, then create a knowledge base for chat retrieval.",
+ "enableGuideStepOpen": "Open knowledge base settings",
+ "enableGuideStepOpenDetail": "Click Knowledge base settings below to manage the instance switch and embedding model.",
+ "enableGuideStepToggle": "Enable knowledge bases",
+ "enableGuideStepToggleDetail": "Turn on Enable knowledge bases. When off, knowledge bases are unavailable for everyone.",
+ "enableGuideStepModel": "Configure an embedding model",
+ "enableGuideStepModelDetail": "Choose local ONNX (download the model first) or an online embedding provider, then save.",
+ "noDescription": "No description",
+ "selectBase": "Select a knowledge base to view its documents",
+ "backToList": "Back to list",
+ "createdBy": "Created by {{name}}",
+ "baseId": "Knowledge base ID",
+ "copyBaseId": "Click to copy knowledge base ID",
+ "collapseListPanel": "Collapse list",
+ "expandListPanel": "Expand list",
+ "documentCount": "{{count}} documents",
+ "documentTotal": "{{count}} documents in total",
+ "defaultOpen": "Default enabled",
+ "defaultOpenBadge": "Default on",
+ "defaultOpenHint": "Can be on together with Shared. When on, only your chats include this knowledge base by default. Other users who can see a shared base still select it manually. When off, you must select it in chat.",
+ "defaultOpenWarning": "When enabled, your chats include this knowledge base by default (using more context); it can be removed for this turn.",
+ "shared": "Shared",
+ "sharedBadge": "Shared",
+ "sharedHint": "Can be on together with Default enabled. Everyone signed in to this instance can read and select it; only the owner can edit it. Default-open applies only to the owner.",
+ "members": "Members",
+ "share": "Share",
+ "noMembers": "Only you can access this knowledge base.",
+ "member": "User {{id}} · {{role}}",
+ "userId": "User ID",
+ "userIdRequired": "Please enter a user ID",
+ "role": "Role",
+ "roles": {
+ "viewer": "Viewer",
+ "editor": "Editor"
+ },
+ "documents": "Documents",
+ "documentLimit": "{{count}} / {{max}} documents",
+ "documentLimitReached": "This knowledge base already contains the maximum of {{count}} documents.",
+ "documentTooLarge": "Each document must be at most {{sizeMb}} MB.",
+ "baseLimitReached": "You can create at most {{count}} knowledge bases.",
+ "uploadHint": "Supports md / txt / pdf / docx / pptx. Max {{sizeMb}} MB per file.",
+ "viewCard": "Cards",
+ "viewTable": "Table",
+ "updatedAt": "Updated",
+ "rebuildDocument": "Rebuild index",
+ "rebuildDocumentConfirm": "Re-parse and re-embed this document?",
+ "rebuildDocumentSuccess": "Document reindex started",
+ "rebuildDocumentFailed": "Failed to rebuild document index",
+ "previewDocument": "Preview",
+ "previewEmpty": "(No text content)",
+ "previewFailed": "Failed to preview document",
+ "icon": "Icon",
+ "iconPlaceholder": "Choose a knowledge-base icon",
+ "iconLabels": {
+ "book-open": "Humanities",
+ "landmark": "History",
+ "users": "Social sciences",
+ "scale": "Law",
+ "flask-conical": "Science",
+ "atom": "STEM",
+ "wrench": "Technology",
+ "cpu": "Computing",
+ "terminal": "Engineering",
+ "graduation-cap": "Education",
+ "briefcase": "Business",
+ "palette": "Arts",
+ "languages": "Languages"
+ },
+ "upload": "Upload documents",
+ "uploaded": "Documents uploaded",
+ "uploadFailed": "Failed to upload documents",
+ "emptyDocuments": "No documents yet",
+ "filename": "File name",
+ "status": "Status",
+ "chunks": "Chunks",
+ "chunkCount": "{{count}} chunks",
+ "statuses": {
+ "pending": "Pending",
+ "processing": "Processing",
+ "ready": "Ready",
+ "failed": "Failed"
+ },
+ "statusesShort": {
+ "pending": "Pending",
+ "processing": "Indexing",
+ "ready": "Ready",
+ "failed": "Failed"
+ },
+ "deleteConfirm": "Delete this knowledge base and all of its documents?",
+ "deleteDocumentConfirm": "Delete this document?",
+ "loadFailed": "Failed to load knowledge bases",
+ "saveFailed": "Failed to save knowledge base changes",
+ "deleteFailed": "Failed to delete",
+ "created": "Knowledge base created",
+ "updated": "Knowledge base updated",
+ "deleted": "Knowledge base deleted",
+ "resizeSidebar": "Resize knowledge base list",
+ "enableTitle": "Enable Knowledge Bases",
+ "enableDescription": "Choose a local ONNX or online embedding model. Changing the model rebuilds all knowledge indexes.",
+ "selectModel": "Select embedding model",
+ "selectProvider": "Select an online provider",
+ "noProviders": "No configured providers yet. Add one in model settings first.",
+ "noModels": "No embedding models available",
+ "localOnnx": "Local ONNX",
+ "remoteEmbedding": "Online embedding",
+ "rebuildConfirmTitle": "Rebuild all knowledge indexes?",
+ "rebuildConfirmDescription": "Changing the embedding model clears and re-embeds every knowledge index.",
+ "notDownloaded": "not downloaded",
+ "approxSize": "about {{size}}",
+ "sizeUnknown": "size unknown",
+ "recommended": "Recommended",
+ "showMoreOnnx": "Show more",
+ "downloadModel": "Download",
+ "downloadNeedModel": "The selected model is not downloaded yet. Download it before saving.",
+ "onnxServiceEnabled": "Local ONNX service enabled",
+ "enable": "Enable",
+ "featureEnabled": "Knowledge bases enabled",
+ "featureDisabled": "Knowledge bases disabled",
+ "featureSaveFailed": "Failed to update knowledge base feature"
},
"setupWizard": {
"title": "Setup Wizard",
@@ -375,6 +557,45 @@
"subtitle": "Spin up a pre-configured agent (with identity / soul / heartbeat baked in) from a scenario template. Each expert ships with its own system prompt — once created, just bind a provider and start it.",
"loadFailed": "Failed to load experts",
"loadDetailFailed": "Failed to load expert details",
+ "share": {
+ "toggle": "Share with other users",
+ "badge": "Shared",
+ "fromOwner": "Shared expert · from {{name}}"
+ },
+ "published": {
+ "title": "Publish template",
+ "badge": "Published",
+ "cardPublish": "Publish template",
+ "publishConfirmTitle": "Publish as template?",
+ "publishConfirm": "This creates a snapshot template. Other users can install a private copy under Built-in Experts → Published by users. Your chat history is not shared.",
+ "statusPublished": "This agent is published as a community template.",
+ "statusNotPublished": "Publish this agent so other users can install a private copy.",
+ "publish": "Publish",
+ "publishSuccess": "Template published",
+ "publishSuccessHint": "Template published. Others can install it under Built-in Experts → Published by users.",
+ "publishFailed": "Failed to publish template",
+ "update": "Update published template",
+ "updateSuccess": "Published template updated",
+ "updateFailed": "Failed to update published template",
+ "unpublish": "Unpublish",
+ "unpublishConfirm": "Unpublish this template? Existing private installs are not affected.",
+ "unpublishSuccess": "Template unpublished",
+ "unpublishFailed": "Failed to unpublish template",
+ "listTitle": "Published by users ({{count}})",
+ "listHint": "Templates published by users on this instance. Click Install to create your own private expert.",
+ "emptyHint": "No user-published templates yet. On My Experts, click the upload icon on a card to publish one.",
+ "by": "by {{name}}",
+ "install": "Install",
+ "installTitle": "Install \"{{name}}\"",
+ "installFailed": "Failed to install template",
+ "drawerTitle": "Publish expert template",
+ "drawerHint": "Only expert config markdown (SOUL, IDENTITY, …), skills, and sub-agent definitions are included. Runtime data such as memory DB, uploads, and built-in skills is excluded.",
+ "fieldName": "Template name",
+ "fieldDescription": "Description",
+ "fieldWelcomeZh": "Welcome message (Chinese)",
+ "fieldWelcomeEn": "Welcome message (English)",
+ "fieldWelcomePlaceholder": "One-line greeting shown on the chat welcome screen"
+ },
"noExperts": "No experts available",
"selectExpertHint": "Pick an expert on the left to view details",
"creating": "Creating…",
@@ -473,6 +694,8 @@
"basicInfo": "Basic Info",
"agentName": "Name",
"agentDescription": "Description",
+ "color": "Color",
+ "colorHint": "Choose the accent color for list cards and chat avatars.",
"defaultModel": "Default Model",
"defaultModelAuto": "Auto (use provider default)",
"editFile": "Edit →",
@@ -597,6 +820,7 @@
"skillFilesHint": "Skills bundled with this expert. They will be written to the workspace skills/ directory after creation.",
"noSkillFiles": "This expert has no bundled skills",
"subagentFilesTitle": "Subagents ({{count}})",
+ "subagentTemplateHint": "Subagents bundled with this expert. They will be written to the workspace agents/ directory after creation.",
"subagentFilesHint": "Subagents defined in the workspace agents/ directory. The main agent delegates tasks to them via the task tool.",
"noSubagentFiles": "No subagents in workspace",
"manageSubagents": "Manage subagents",
@@ -645,8 +869,14 @@
"pleaseInputDescription": "Please enter a description",
"emojiLabel": "Emoji",
"emojiPlaceholder": "🤖",
+ "emojiHint": "Pick an icon for this subagent from the list.",
+ "emojiPick": "Choose emoji",
+ "emojiPickerLabel": "Emoji",
+ "emojiSearchPlaceholder": "Filter emoji",
+ "emojiNoResults": "No matching emoji",
"colorLabel": "Color",
"colorPlaceholder": "Optional, e.g. cyan or #6366f1",
+ "colorHint": "Choose a color for the subagent card in the installed list.",
"bodyLabel": "Instructions",
"bodyPlaceholder": "Write the subagent personality and instructions in Markdown…",
"pleaseInputBody": "Please enter instructions",
@@ -690,6 +920,9 @@
"model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models."
},
"chat": {
+ "sharedExpert": {
+ "banner": "Shared expert · provided by {{name}}"
+ },
"thinking": "Thinking",
"continuing": "Continuing",
"generating": "Generating",
@@ -700,6 +933,11 @@
"refreshingMessages": "Refreshing messages…",
"streamResumed": "Connection restored. Pull down to refresh if anything looks incomplete.",
"regenerate": "Regenerate",
+ "forkFromHere": "Fork from here",
+ "forkSuccess": "Forked into a new chat",
+ "forkSuccessEmpty": "Forked into a new empty chat — edit your question and send",
+ "forkFailed": "Failed to fork this conversation",
+ "forkDisabledWhileBusy": "Wait for the current reply or approval to finish",
"retry": "Retry",
"like": "Helpful",
"dislike": "Not helpful",
@@ -765,6 +1003,10 @@
"title": "Expert details"
},
"composerMore": "More tools",
+ "knowledgePicker": "Knowledge bases",
+ "knowledgePickerSearch": "Search knowledge bases",
+ "knowledgePickerEmpty": "No knowledge bases available",
+ "manageKnowledgeBases": "Manage knowledge bases",
"skillPicker": "Select skills",
"skillPickerSearch": "Search skills",
"skillPickerEmpty": "No skills available",
@@ -824,6 +1066,7 @@
"write_file": "Write file",
"edit_file": "Edit file",
"execute": "Execute",
+ "bash": "Shell (bash)",
"current_time": "Current time",
"write_todos": "Write plan",
"task": "Sub-agent task",
@@ -846,6 +1089,7 @@
"cronjob_update": "Update cron job",
"cronjob_delete": "Delete cron job",
"cronjob_run_now": "Run cron job now",
+ "search_knowledge": "Search knowledge base",
"agent_list": "List agents",
"ask_agent": "Ask agent",
"call_agent": "Call agent",
@@ -976,7 +1220,27 @@
"importConfirmOk": "Restore",
"importSuccess": "Restore complete ({{agents}} agents, {{files}} workspace files)",
"importFailed": "Failed to restore backup",
- "restoreConfig": "Also restore config.json and env (requires a manual service restart to apply)"
+ "restoreConfig": "Also restore config.json and env (requires a manual service restart to apply)",
+ "autoTitle": "Automatic backup",
+ "autoDesc": "Schedule full system backups into the backups directory. Only automatic archives are pruned by retention.",
+ "autoEnabled": "Enable automatic backup",
+ "autoSchedule": "Schedule",
+ "autoScheduleDaily": "Daily at 04:00",
+ "autoScheduleWeekly": "Weekly on Sunday at 04:00",
+ "autoSchedule12h": "Every 12 hours",
+ "autoScheduleCustom": "Custom",
+ "autoScheduleHint": "cron:m h dom mon dow (server timezone); interval:, e.g. interval:43200 means every 12 hours.",
+ "autoIntervalPreview": "Current: every {{seconds}} seconds (about {{hours}} h)",
+ "autoRetention": "Keep last N automatic backups",
+ "autoSave": "Save settings",
+ "autoSaveSuccess": "Automatic backup settings saved",
+ "autoSaveFailed": "Failed to save automatic backup settings",
+ "autoLoadFailed": "Failed to load automatic backup settings",
+ "autoRunNow": "Run now",
+ "autoRunSuccess": "Automatic backup created",
+ "autoRunFailed": "Failed to run automatic backup",
+ "autoScheduled": "Scheduler: active",
+ "autoNotScheduled": "Scheduler: inactive"
},
"skills": {
"title": "Skills",
@@ -994,6 +1258,8 @@
"invalidSkillUrlSource": "Enter a valid HTTP(S) skill URL; supported sources are validated by the server adapter",
"zipHintTitle": "ZIP layout (each top-level folder = one skill):",
"zipHintDetail": "Each folder must contain SKILL.md and may include scripts or other files. Only .zip is supported.",
+ "zipDragDropHint": "Drag and drop your files here to upload",
+ "zipSelected": "Selected: {{name}}",
"chooseZip": "Choose ZIP file",
"noZipSelected": "No file selected",
"removeZip": "Remove",
@@ -1014,6 +1280,12 @@
"createSkill": "Create Skill",
"viewSkill": "View Skill",
"editSkill": "Edit Skill",
+ "fileTreeTitle": "Skill files",
+ "fileTreeEmpty": "No files",
+ "fileTreeShow": "Show skill files",
+ "fileTreeHide": "Hide skill files",
+ "fileTreeAgentNotReady": "Agent is not running — skill files cannot be loaded",
+ "finishEditBeforeSwitchFile": "Save or cancel editing before switching files",
"saveSkill": "Save",
"viewPreview": "Preview",
"viewSource": "Source",
@@ -1102,6 +1374,8 @@
"addMetadata": "Add field",
"metadataKeyRequired": "Key is required",
"metadataValueRequired": "Value is required",
+ "emojiLabel": "Emoji",
+ "emojiHint": "Pick an icon for this skill from the list.",
"bodyLabel": "Skill implementation",
"bodyPlaceholder": "Describe when to use this skill and how to execute it (Markdown)",
"pleaseInputDescription": "Please enter a skill description",
@@ -1387,6 +1661,10 @@
"enableChannel": "Enable channel",
"enableChannelDesc": "When off, the channel stops receiving and sending messages but keeps its configuration",
"displaySettings": "Message display",
+ "responseMode": "Response mode",
+ "responseModeDesc": "Final response hides progress narration before tool calls; live progress keeps the current staged messages",
+ "responseModeInvoke": "Final only (Recommended)",
+ "responseModeStream": "Live progress",
"showToolHints": "Show tool hints",
"showToolHintsDesc": "When enabled, channel messages show tool-call activity and status hints",
"globalSettings": "Global Settings",
@@ -1639,7 +1917,9 @@
"tencent-hai": "Tencent Cloud HAI",
"mimo": "Xiaomi MiMo",
"minimax": "MiniMax",
- "volces": "Volcengine"
+ "volces": "Volcengine",
+ "ollama": "Ollama (Local)",
+ "onnx": "ONNX (Local)"
},
"voice": {
"loading": "Loading voice settings…",
@@ -1699,6 +1979,11 @@
"hitlTools": "Tools requiring approval",
"hitlHint": "Listed tools pause before execution until approved or rejected in chat.",
"hitlEnableWarning": "Enabling tool approval will pause the agent whenever a listed tool is about to run, requiring manual confirmation. This may add friction: in IM channels you must send /approve or /reject to continue; in the Dashboard an approval card will appear. Make sure you are comfortable with these extra steps.",
+ "hitlToolsPickerHint": "Check tools that require human approval before execution. The tool id is shown below each label.",
+ "hitlToolsSelectDefaults": "Recommended defaults",
+ "hitlToolsSelectAll": "Select all",
+ "hitlToolsDeselectAll": "Clear all",
+ "hitlToolsEmpty": "No approvable tools are available.",
"fsDesc": "Deny filesystem tools from reading or writing sensitive paths.",
"fsEnable": "Enable filesystem path rules",
"fsPaths": "Sensitive paths (one per line, glob supported)",
@@ -1927,6 +2212,31 @@
"localDeleteFailed": "Failed to delete model",
"localDownloadPending": "Preparing to download...",
"localDownloading": "Downloading {{repo}}... This may take a few minutes.",
+ "localDownloadConfirmTitle": "Confirm model download",
+ "localDownloadConfirmOnnx": "Download {{name}} (about {{size}}). This may take a few minutes.",
+ "localDownloadConfirmOllama": "Pull {{name}} via Ollama. Size depends on the model and may take a while.",
+ "localDownloadSizeUnknown": "size unknown",
+ "localDownloadProgressTitle": "Downloading model",
+ "localDownloadPreparing": "Preparing download…",
+ "localDownloadContinueBackground": "Continue in background",
+ "localDownloadBackground": "Download moved to the background. You will be notified when it finishes.",
+ "localDownloadBackgroundHint": "You can close this window. The download continues on the server and you will get a notification when it finishes.",
+ "onnxDownloadProgress": "Downloading {{model}} ({{percent}}%)",
+ "onnxDownloadLoading": "Loading {{model}}…",
+ "defaultModelDownloadedOnly": "Only downloaded models can be selected",
+ "defaultModelNeedDownload": "Download a model below first",
+ "downloadBeforeEnable": "Download the model before enabling it",
+ "notDownloaded": "Not downloaded",
+ "localModelDownloaded": "Downloaded",
+ "localServiceHint": "Start or stop the local service",
+ "localServiceLabel": "Service",
+ "localRuntime": "Local runtime",
+ "localServiceRunning": "Service running",
+ "localServiceOn": "Service on",
+ "localServiceOff": "Service off",
+ "localServiceStarted": "Local service started",
+ "localServiceStopped": "Local service stopped",
+ "localServiceToggleFailed": "Failed to toggle local service",
"localCancelDownload": "Cancel Download",
"localCancelDownloadConfirm": "Cancel download of \"{{repo}}\"?",
"localDownloadCancelled": "Download cancelled",
@@ -2070,7 +2380,43 @@
"fetchModelsFailed": "Failed to fetch models: {{error}}",
"fetchModelsUnsupportedKind": "Auto-fetch is only supported for OpenAI-compatible providers",
"customModelsLabel": "Models",
- "customModelsHint": "Fetched models are off by default — enable, test, edit, or remove; or add manually"
+ "customModelsHint": "Fetched models are off by default — enable, test, edit, or remove; or add manually",
+ "onnxLocalService": "Local ONNX embedding",
+ "onnxServiceHint": "Local ONNX embedding model cache (download / enable / probe). Not a chat model; not used by Memory.",
+ "onnxNoProviderForm": "Local cache only — enable the service, pick a catalog model, download weights. Not chat and not Memory.",
+ "onnxModelLabel": "Model",
+ "onnxModelPlaceholder": "Select a catalog model",
+ "onnxApply": "Apply",
+ "onnxRecommended": "Recommended",
+ "onnxDepsPending": "Local embedding components are not ready yet. They install automatically when you turn the service on.",
+ "onnxDepsInstallFailed": "Automatic installation failed. Check your network connection and try again.",
+ "onnxInstallingDeps": "Installing ONNX local dependencies — this may take a few minutes…",
+ "onnxDepsInstalled": "ONNX local dependencies installed",
+ "enableAfterDownloadFailed": "The model downloaded, but auto-enable failed. Turn it on manually and save.",
+ "embeddingOnlyTag": "Embedding only (not in chat / Auto)",
+ "embeddingModel": "Embedding model",
+ "embeddingModelHint": "When on, this model is only used for knowledge-base retrieval and is hidden from chat / Auto.",
+ "onnxTestNeedDownload": "Download the model before testing",
+ "onnxCached": "Cached",
+ "onnxSelectModel": "Select or enter a model id first",
+ "onnxEnabled": "ONNX local service enabled",
+ "onnxDisabled": "ONNX local service disabled",
+ "onnxModelApplied": "Model set to {{model}}",
+ "onnxDownloadStarted": "Downloading {{model}}…",
+ "onnxDownloadDone": "Downloaded {{model}}",
+ "onnxDownloadFailed": "ONNX model download failed",
+ "onnxLoadFailed": "Failed to load ONNX service status",
+ "onnxSaveFailed": "Failed to update ONNX service",
+ "onnxDeleteFailed": "Failed to delete cached model",
+ "onnxModelDeleted": "Deleted cached model {{model}}",
+ "onnxLoading": "Loading {{model}}…",
+ "onnxDownloading": "Downloading {{model}}…",
+ "onnxStatusLine": "Status: {{ready}} · model {{model}}",
+ "onnxReady": "ready",
+ "onnxNotReady": "not ready",
+ "onnxCacheDir": "Cache: {{dir}}",
+ "onnxLocalCached": "Cached models",
+ "onnxQuickDownload": "Quick download from catalog…"
},
"advancedSettings": {
"description": "Manage runtime configuration and environment variables.",
@@ -2822,9 +3168,9 @@
"installSuccess": "Browser installed successfully",
"installSuccessHint": "Browser is ready — you can start a session",
"installFailed": "Installation failed",
- "installFailedHint": "Retry or run playwright install chromium manually",
+ "installFailedHint": "Automatic installation failed. Try again. If downloads are slow, set PLAYWRIGHT_DOWNLOAD_HOST to a mirror URL and retry.",
"notInstalled": "Chromium not installed",
- "notInstalledHint": "Run the auto-installer below or manually run playwright install chromium.",
+ "notInstalledHint": "No usable browser found. Use the button below to install the bundled Chromium automatically.",
"install": "Install browser",
"installProgress": "Installing…",
"installCancelHint": "Install request cancelled. The server may still be installing — refresh status later.",
@@ -3410,7 +3756,26 @@
"submit": "Sign in",
"failed": "Login failed",
"slideHint": "Slide to the end to verify",
- "slideVerified": "Verified"
+ "slideVerified": "Verified",
+ "or": "or",
+ "oidcWith": "Continue with {{name}}",
+ "oidcStartFailed": "Could not start single sign-on",
+ "oidcError": {
+ "denied": "Single sign-on was cancelled.",
+ "state": "Your single sign-on session expired. Please try again.",
+ "disabled": "Single sign-on is unavailable.",
+ "misconfigured": "Single sign-on is misconfigured.",
+ "invalid_token": "The identity provider returned an invalid token.",
+ "exchange": "Could not verify your identity-provider login.",
+ "generic": "Single sign-on failed. Please try again."
+ },
+ "oidcComplete": {
+ "title": "Completing single sign-on",
+ "loading": "Signing you in…",
+ "missingCode": "The single sign-on response is missing a login code.",
+ "failed": "Could not complete single sign-on",
+ "backToLogin": "Back to sign in"
+ }
},
"adminUsers": {
"newUser": "New user",
@@ -3438,6 +3803,8 @@
"colId": "ID",
"colUsername": "Username",
"colDisplayName": "Display name",
+ "colEmail": "Email",
+ "colAuth": "Sign-in methods",
"colCreatedAt": "Created",
"colRole": "Role",
"colLoginLock": "Login lock",
@@ -3464,7 +3831,57 @@
"noUsers": "No users yet",
"totalUsers": "{{count}} user(s)",
"statusEnabled": "Enabled",
- "statusDisabled": "Disabled"
+ "statusDisabled": "Disabled",
+ "ssoBadge": "SSO",
+ "passwordBadge": "Password",
+ "tabLocal": "Local users",
+ "tabSso": "Single sign-on",
+ "colPermissions": "Permissions",
+ "permAll": "All (admin)",
+ "permAllHint": "Admins automatically have every module permission; no need to select them.",
+ "permEditHint": "Grant management pages and write access per module",
+ "permGroupSettings": "Settings",
+ "permGroupControl": "Control",
+ "permGroupAdmin": "Admin",
+ "permCatalogEmpty": "No permission items available",
+ "permCount": "{{count}} selected",
+ "createSectionAccount": "Account",
+ "createSectionAccess": "Role & permissions",
+ "modalEditTitle": "Edit user {{username}}"
+ },
+ "adminSso": {
+ "enabled": "Enable single sign-on",
+ "enabledHint": "Allow users to sign in through the configured OpenID Connect provider.",
+ "displayName": "Provider display name",
+ "displayNameRequired": "Enter a provider display name",
+ "issuer": "Issuer URL",
+ "issuerHint": "The OpenID Connect issuer URL published by your identity provider.",
+ "issuerRequired": "Enter a valid issuer URL",
+ "clientId": "Client ID",
+ "clientIdRequired": "Enter the client ID",
+ "clientSecret": "Client secret",
+ "clientSecretHint": "Optional for public clients.",
+ "clientSecretConfigured": "A client secret is configured. Leave blank to keep it unchanged.",
+ "clientSecretPlaceholder": "Leave blank to keep the current secret",
+ "scopes": "Scopes",
+ "scopesRequired": "Enter at least one scope",
+ "dashboardOrigin": "Dashboard origin override",
+ "dashboardOriginHint": "Optional public dashboard URL used after the identity provider redirects back.",
+ "dashboardOriginInvalid": "Enter a valid dashboard origin URL",
+ "redirectUri": "Redirect URI",
+ "redirectUriHint": "Add this exact callback URL to your identity provider configuration.",
+ "copy": "Copy",
+ "copyRedirectUri": "Copy redirect URI",
+ "copySuccess": "Redirect URI copied",
+ "copyFailed": "Could not copy redirect URI",
+ "save": "Save",
+ "saved": "Single sign-on settings saved",
+ "saveFailed": "Could not save single sign-on settings",
+ "loadFailed": "Could not load single sign-on settings",
+ "testConnection": "Test connection",
+ "testSuccess": "OIDC connection succeeded",
+ "testFailed": "OIDC connection test failed",
+ "testHint": "Save changes before testing a new issuer or client ID."
},
"adminAudit": {
"loadFailed": "Load failed",
@@ -3630,10 +4047,10 @@
"addConnection": "Add {{name}}",
"displayName": "Display name",
"defaultOpen": "Open by default",
- "defaultOpenHint": "When off, tools are injected only if you select this connector in chat.",
- "defaultOpenWarning": "When on, tools are included by default in Dashboard, IM, and Cron jobs with no connector picks (extra tokens). Dashboard can opt out per turn; Cron explicit picks override defaults.",
+ "defaultOpenHint": "Applies only to your account. When off, tools are injected only if you select this connector in chat.",
+ "defaultOpenWarning": "When on, tools are included by default in your Dashboard, IM, and Cron jobs with no connector picks (extra tokens). Dashboard can opt out per turn; Cron explicit picks override defaults.",
"defaultOpenLockedBadge": "Default on",
- "defaultOpenLockedHint": "Marked default-open. Auto-injected on IM and Cron when no connectors are picked; Dashboard / Cron with explicit picks follow the selection.",
+ "defaultOpenLockedHint": "Marked default-open for your account. Auto-injected on IM and Cron when no connectors are picked; Dashboard / Cron with explicit picks follow the selection.",
"token": "Access token",
"getToken": "Get token",
"goToAuthorize": "Authorize",
@@ -3852,7 +4269,11 @@
},
"adminUsers": {
"title": "User Management",
- "subtitle": "Create accounts and manage admin vs standard user access"
+ "subtitle": "Manage local accounts and single sign-on"
+ },
+ "adminSso": {
+ "title": "Single sign-on",
+ "subtitle": "Configure an OpenID Connect identity provider"
},
"adminStorage": {
"title": "Storage",
diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json
index 6b411fe3..97ea00e9 100644
--- a/dashboard/src/locales/zh.json
+++ b/dashboard/src/locales/zh.json
@@ -18,6 +18,7 @@
"view": "查看",
"create": "创建",
"add": "添加",
+ "actions": "操作",
"upload": "上传",
"download": "下载",
"refresh": "刷新",
@@ -37,8 +38,15 @@
"askOctopHint": "若安装始终失败,可复制日志后询问 Octop,让它帮你排查原因。",
"close": "关闭",
"contentPlaceholder": "输入内容...",
+ "emojiPick": "选择 Emoji",
+ "emojiPickerLabel": "Emoji",
+ "emojiSearchPlaceholder": "筛选 Emoji",
+ "emojiNoResults": "没有匹配的 Emoji",
"gotIt": "知道了",
"unknownError": "未知错误",
+ "noPermission": "没有权限",
+ "noPermissionHint": "你没有访问此页面的权限,请联系管理员开通。",
+ "backToChat": "返回聊天",
"viewDetail": "查看详情",
"viewMore": "查看更多",
"deleteFailed": "删除失败",
@@ -61,6 +69,7 @@
"SETUP_REQUIRED": "需要完成初始设置。",
"DATABASE_NOT_EMPTY": "目标数据库已有用户。请改用空库,或直接登录现有管理员账户。",
"BACKUP_DRIVER_MISMATCH": "该备份与当前数据库引擎不一致(SQLite 与 PostgreSQL 不能互恢)。请将运行时切回备份所用引擎后再恢复,或在当前引擎上重新备份。暂不支持跨引擎恢复。",
+ "BACKUP_IN_PROGRESS": "已有备份任务正在进行,请稍后再试。",
"FORBIDDEN": "没有权限。",
"NOT_FOUND": "未找到。",
"USER_DISABLED": "账号已禁用。",
@@ -79,6 +88,7 @@
"PROVIDER_NAME_TAKEN": "提供商名称已被占用。",
"PROVIDER_NOT_VISIBLE": "提供商不可见。",
"PROVIDER_REFERENCED": "提供商正在使用中,无法删除。",
+ "PROVIDER_LOCAL_PROTECTED": "本地提供商无法删除。",
"STORAGE_BACKEND_NAME_TAKEN": "存储后端名称已被占用。",
"STORAGE_BACKEND_REFERENCED": "存储后端正在使用中,无法删除。",
"CHANNEL_KIND_UNSUPPORTED": "不支持的通道类型。",
@@ -109,13 +119,25 @@
"SKILL_PACKAGE_NOT_FOUND": "未找到技能包。",
"SKILL_PACKAGE_NAME_TAKEN": "技能包 {{name}} 已存在。",
"SKILL_PACKAGE_BACKEND_UNSUPPORTED": "技能包目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录须为 /(POSIX)或 Agent 工作区根目录(Windows 默认)。",
+ "PUBLISHED_EXPERT_SLUG_TAKEN": "已存在发布专家 slug:{{slug}}。",
+ "PUBLISHED_EXPERT_ALREADY_EXISTS": "该专家已发布为「{{name}}」,请改为刷新已发布模板。",
+ "OIDC_BAD_REQUEST": "OIDC 请求无效:{{detail}}",
"EXPERT_MARKET_FAILED": "专家市场暂时不可用,请稍后重试。",
"SKILLHUB_SSL_FAILED": "SkillHub 请求失败:检测到 Python SSL 错误。macOS 可尝试 `brew reinstall openssl@3`,或检查系统 CA 证书。",
"DESKTOP_SESSION_LIMIT": "远程桌面并发连接已达上限(最多 {{limit}} 路),请关闭其他连接后重试。",
"DESKTOP_CAPTURE_FAILED": "连续抓屏失败。Linux 服务器请重启虚拟桌面;macOS 请在系统设置中授予屏幕录制与辅助功能权限后重连。",
"PLUGIN_INVALID_ARCHIVE": "下载内容不是有效的插件 ZIP。请使用直接的 .zip 下载地址(GitHub 请用 raw.githubusercontent.com 或 “Download/raw” 链接,不要用 /blob/ 页面地址)。",
"PLUGIN_INSTALL_FAILED": "下载或安装插件失败:{{reason}}",
- "PLUGIN_ALREADY_EXISTS": "插件 {{id}} 已安装。"
+ "PLUGIN_ALREADY_EXISTS": "插件 {{id}} 已安装。",
+ "KNOWLEDGE_FEATURE_DISABLED": "管理员尚未启用知识库功能。",
+ "KNOWLEDGE_PREREQUISITES_FAILED": "知识库的向量模型或依赖尚未就绪。",
+ "KNOWLEDGE_NOT_FOUND": "未找到知识库或文档。",
+ "KNOWLEDGE_FORBIDDEN": "您没有此知识库的访问权限。",
+ "KNOWLEDGE_DOC_LIMIT": "每个知识库最多可包含 100 个文档。",
+ "KNOWLEDGE_DOC_TOO_LARGE": "文档超过 20 MB 大小限制。",
+ "KNOWLEDGE_BASE_LIMIT": "每个用户最多可创建 20 个知识库。",
+ "KNOWLEDGE_UNSUPPORTED_TYPE": "知识库不支持此文档类型。",
+ "KNOWLEDGE_NAME_TAKEN": "您已有同名知识库。"
},
"nav": {
"chat": "聊天",
@@ -162,12 +184,14 @@
"newVersionBadge": "有新版本",
"admin": "管理",
"adminUsers": "用户",
+ "adminSso": "单点登录",
"adminStorage": "存储",
"adminAudit": "审计日志",
"adminAgents": "智能体",
"adminAdvanced": "应用设置",
"security": "安全防护",
- "adminPlugins": "插件管理",
+ "knowledgeBases": "知识库",
+ "adminPlugins": "插件",
"skillPackages": "技能包"
},
"skillPackages": {
@@ -189,6 +213,14 @@
"fromSkillHubHint": "将 SkillHub 技能集导入为新的技能包。",
"addAsPackage": "添加为技能包",
"empty": "暂无技能包",
+ "emptyGuideTitle": "还没有技能包",
+ "emptyGuideDesc": "技能包把一组可复用的技能收在一起,挂到专家后即可在对话中使用。",
+ "emptyGuideStepWhat": "技能是什么",
+ "emptyGuideStepWhatDetail": "技能是给专家的操作说明书(SKILL.md),说明如何完成一类任务,例如检索资料、填写表格或调用工具。",
+ "emptyGuideStepHow": "怎么用",
+ "emptyGuideStepHowDetail": "创建技能包后,手动添加技能或从技能市场导入;再到「专家」中挂载该包,之后对话会自动带上这些技能。",
+ "emptyGuideStepShare": "共享有什么作用",
+ "emptyGuideStepShareDetail": "技能包对本实例的登录用户可见。其他人可以挂载使用,只有创建者可以编辑或删除。",
"selectPackage": "选择一个技能包以查看其中的技能",
"backToList": "返回列表",
"noDescription": "暂无描述",
@@ -213,7 +245,157 @@
"skillSaved": "技能已保存",
"skillDeleted": "技能已删除",
"resizeSidebar": "调整技能包列表宽度",
- "createdBy": "创建者:{{name}}"
+ "createdBy": "创建者:{{name}}",
+ "packageId": "技能包 ID",
+ "copyPackageId": "点击复制技能包 ID",
+ "collapseListPanel": "收起列表",
+ "expandListPanel": "展开列表"
+ },
+ "knowledgeBases": {
+ "title": "知识库",
+ "subtitle": "上传文档建立索引,在对话中检索引用;可按需共享给同实例用户。",
+ "featureTitle": "启用知识库",
+ "settingsTitle": "知识库设置",
+ "settingsLead": "实例级开关。启用后需配置向量模型;关闭后全实例不可使用知识库。",
+ "settingsOpen": "启用知识库",
+ "manageModels": "管理模型",
+ "unavailableTitle": "知识库尚未就绪",
+ "unavailableDescription": "请先在右上角设置中启用知识库,并配置可用的向量模型。",
+ "unavailableDescriptionNonAdmin": "知识库尚未启用或未配置向量模型,请联系管理员。",
+ "create": "创建知识库",
+ "edit": "编辑知识库",
+ "name": "名称",
+ "nameRequired": "请输入知识库名称",
+ "description": "描述",
+ "empty": "暂无知识库",
+ "emptyGuideTitle": "还没有知识库",
+ "emptyGuideDesc": "把文档做成可检索的资料库,对话时引用,也可按需共享给同实例用户。",
+ "emptyGuideStepWhat": "什么是知识库?",
+ "emptyGuideStepWhatDetail": "知识库会把上传的文档切分并向量化。对话时专家可以检索引用相关段落,而不必把全文塞进上下文。",
+ "emptyGuideStepHow": "知识库怎么用",
+ "emptyGuideStepHowDetail": "创建知识库并上传 md / txt / pdf / docx / pptx。可设为默认启用,或在对话中手动勾选。",
+ "emptyGuideStepShare": "共享有什么作用",
+ "emptyGuideStepShareDetail": "共享后实例内登录用户可读、可选用;仅所有者可编辑。默认启用只对创建者生效。",
+ "enableGuideTitle": "怎么开启知识库功能?",
+ "enableGuideDesc": "知识库是实例级功能。启用后需配置向量模型,即可创建知识库并在对话中检索文档。",
+ "enableGuideStepOpen": "打开知识库设置",
+ "enableGuideStepOpenDetail": "点击下方「知识库设置」,进入实例级开关与向量模型配置。",
+ "enableGuideStepToggle": "启用知识库",
+ "enableGuideStepToggleDetail": "打开「启用知识库」开关。关闭后全实例都无法使用知识库。",
+ "enableGuideStepModel": "配置向量模型",
+ "enableGuideStepModelDetail": "选择本地 ONNX(需先下载模型)或在线 Embedding 提供商,保存后即可使用。",
+ "noDescription": "暂无描述",
+ "selectBase": "选择一个知识库以查看文档",
+ "backToList": "返回列表",
+ "createdBy": "创建者:{{name}}",
+ "baseId": "知识库 ID",
+ "copyBaseId": "点击复制知识库 ID",
+ "collapseListPanel": "收起列表",
+ "expandListPanel": "展开列表",
+ "documentCount": "{{count}} 个文档",
+ "documentTotal": "共有 {{count}} 个文档",
+ "defaultOpen": "是否默认启用",
+ "defaultOpenBadge": "默认开启",
+ "defaultOpenHint": "可与「是否共享」同时开启。开启后仅你自己的对话会默认带上此知识库;其他人即使能看到共享库也不会自动带上。关闭后需在对话中手动勾选。",
+ "defaultOpenWarning": "开启后你自己的聊天默认带上此知识库(消耗更多上下文);可在本轮取消",
+ "shared": "是否共享",
+ "sharedBadge": "已共享",
+ "sharedHint": "可与「是否默认启用」同时开启。共享后实例内所有登录用户可读、可选用;仅所有者可编辑。默认启用只对创建者生效。",
+ "members": "成员",
+ "share": "分享",
+ "noMembers": "当前只有您可以访问此知识库。",
+ "member": "用户 {{id}} · {{role}}",
+ "userId": "用户 ID",
+ "userIdRequired": "请输入用户 ID",
+ "role": "角色",
+ "roles": {
+ "viewer": "只读",
+ "editor": "可编辑"
+ },
+ "documents": "文档",
+ "documentLimit": "{{count}} / {{max}} 个文档",
+ "documentLimitReached": "此知识库已达到 {{count}} 个文档的上限。",
+ "documentTooLarge": "单个文档不能超过 {{sizeMb}} MB。",
+ "baseLimitReached": "每个用户最多可创建 {{count}} 个知识库。",
+ "uploadHint": "支持 md / txt / pdf / docx / pptx,单文件不超过 {{sizeMb}} MB。",
+ "viewCard": "卡片",
+ "viewTable": "表格",
+ "updatedAt": "更新时间",
+ "rebuildDocument": "重建索引",
+ "rebuildDocumentConfirm": "重新解析并嵌入此文档?",
+ "rebuildDocumentSuccess": "已开始重建文档索引",
+ "rebuildDocumentFailed": "重建文档索引失败",
+ "previewDocument": "预览",
+ "previewEmpty": "(无文本内容)",
+ "previewFailed": "预览文档失败",
+ "icon": "图标",
+ "iconPlaceholder": "选择知识库图标",
+ "iconLabels": {
+ "book-open": "人文",
+ "landmark": "历史",
+ "users": "社科",
+ "scale": "法律",
+ "flask-conical": "科学",
+ "atom": "理科",
+ "wrench": "技术",
+ "cpu": "计算机",
+ "terminal": "工程",
+ "graduation-cap": "教育",
+ "briefcase": "商业",
+ "palette": "艺术",
+ "languages": "语言"
+ },
+ "upload": "上传文档",
+ "uploaded": "文档已上传",
+ "uploadFailed": "上传文档失败",
+ "emptyDocuments": "暂无文档",
+ "filename": "文件名",
+ "status": "状态",
+ "chunks": "分块数",
+ "chunkCount": "{{count}} 个分块",
+ "statuses": {
+ "pending": "待处理",
+ "processing": "处理中",
+ "ready": "已就绪",
+ "failed": "失败"
+ },
+ "statusesShort": {
+ "pending": "待索引",
+ "processing": "索引中",
+ "ready": "就绪",
+ "failed": "失败"
+ },
+ "deleteConfirm": "删除此知识库及其所有文档?",
+ "deleteDocumentConfirm": "删除此文档?",
+ "loadFailed": "加载知识库失败",
+ "saveFailed": "保存知识库变更失败",
+ "deleteFailed": "删除失败",
+ "created": "知识库已创建",
+ "updated": "知识库已更新",
+ "deleted": "知识库已删除",
+ "resizeSidebar": "调整知识库列表宽度",
+ "enableTitle": "启用知识库",
+ "enableDescription": "请选择本地 ONNX 或在线 Embedding 向量模型。切换模型会重建全部知识库索引。",
+ "selectModel": "选择向量模型",
+ "selectProvider": "选择在线提供商",
+ "noProviders": "暂无已配置的提供商,请先在模型管理中添加",
+ "noModels": "暂无可用的向量模型",
+ "localOnnx": "本地 ONNX",
+ "remoteEmbedding": "在线 Embedding",
+ "rebuildConfirmTitle": "重建知识库索引?",
+ "rebuildConfirmDescription": "切换向量模型会清空并重新嵌入所有知识库索引。",
+ "notDownloaded": "未下载",
+ "approxSize": "约 {{size}}",
+ "sizeUnknown": "大小未知",
+ "recommended": "推荐",
+ "showMoreOnnx": "展开更多",
+ "downloadModel": "下载",
+ "downloadNeedModel": "所选模型尚未下载,请先下载后再保存。",
+ "onnxServiceEnabled": "已开启本地 ONNX 服务",
+ "enable": "启用",
+ "featureEnabled": "知识库已启用",
+ "featureDisabled": "知识库已停用",
+ "featureSaveFailed": "更新知识库功能失败"
},
"setupWizard": {
"title": "设置向导",
@@ -375,6 +557,45 @@
"subtitle": "基于已配置的场景模板,一键创建预置身份/灵魂/心跳的智能体。每个专家自带 system prompt,新建后再为它绑定 provider 并启动即可。",
"loadFailed": "加载专家失败",
"loadDetailFailed": "加载详情失败",
+ "share": {
+ "toggle": "共享给其他用户",
+ "badge": "已共享",
+ "fromOwner": "共享专家 · 来自 {{name}}"
+ },
+ "published": {
+ "title": "发布模板",
+ "badge": "已发布",
+ "cardPublish": "发布模板",
+ "publishConfirmTitle": "发布为模板?",
+ "publishConfirm": "将把当前专家打成快照模板。其他用户可在「专家库 → 用户发布」中安装为自己的私有副本;不会共享你的对话记录。",
+ "statusPublished": "此 Agent 已发布为社区模板。",
+ "statusNotPublished": "发布此 Agent,让其他用户可以安装私有副本。",
+ "publish": "发布",
+ "publishSuccess": "模板已发布",
+ "publishSuccessHint": "模板已发布。其他用户可在「专家库 → 用户发布」中安装。",
+ "publishFailed": "发布模板失败",
+ "update": "更新已发布模板",
+ "updateSuccess": "已更新发布模板",
+ "updateFailed": "更新发布模板失败",
+ "unpublish": "取消发布",
+ "unpublishConfirm": "确定取消发布此模板?已安装的私有副本不会受影响。",
+ "unpublishSuccess": "模板已取消发布",
+ "unpublishFailed": "取消发布模板失败",
+ "listTitle": "用户发布({{count}})",
+ "listHint": "由本实例用户发布的模板。点击「安装」可为自己创建一份私有专家。",
+ "emptyHint": "还没有用户发布的模板。在「我的专家」卡片上点击上传图标即可发布。",
+ "by": "来自 {{name}}",
+ "install": "安装",
+ "installTitle": "安装「{{name}}」",
+ "installFailed": "安装模板失败",
+ "drawerTitle": "发布专家模板",
+ "drawerHint": "仅打包专家配置 Markdown(SOUL / IDENTITY 等)、Skills 与子 Agent 定义;对话记忆、上传文件、内置 Skills 等运行时数据不会包含。",
+ "fieldName": "模板名称",
+ "fieldDescription": "模板描述",
+ "fieldWelcomeZh": "中文引导语",
+ "fieldWelcomeEn": "英文引导语",
+ "fieldWelcomePlaceholder": "聊天欢迎页展示的一行引导语"
+ },
"noExperts": "暂无可用专家",
"selectExpertHint": "请从左侧选择一个专家以查看详情",
"creating": "创建中…",
@@ -473,6 +694,8 @@
"basicInfo": "基本信息",
"agentName": "名称",
"agentDescription": "描述",
+ "color": "配色",
+ "colorHint": "选择专家卡片配色,用于列表与聊天头像等展示。",
"defaultModel": "默认模型",
"defaultModelAuto": "Auto(使用 Provider 默认模型)",
"editFile": "编辑 →",
@@ -596,6 +819,7 @@
"skillFilesHint": "该专家自带的技能,创建后会写入工作区的 skills/ 目录。",
"noSkillFiles": "该专家暂无自带技能",
"subagentFilesTitle": "子智能体列表 ({{count}})",
+ "subagentTemplateHint": "该专家自带的子智能体,创建后会写入工作区的 agents/ 目录。",
"subagentFilesHint": "工作区 agents/ 目录下的子智能体定义,主智能体通过 task 工具委派任务。",
"noSubagentFiles": "工作区中暂无子智能体",
"manageSubagents": "管理子智能体",
@@ -644,8 +868,14 @@
"pleaseInputDescription": "请输入描述",
"emojiLabel": "Emoji",
"emojiPlaceholder": "🤖",
- "colorLabel": "颜色",
+ "emojiHint": "从列表中选择子智能体图标。",
+ "emojiPick": "选择 Emoji",
+ "emojiPickerLabel": "Emoji",
+ "emojiSearchPlaceholder": "筛选 Emoji",
+ "emojiNoResults": "没有匹配的 Emoji",
+ "colorLabel": "配色",
"colorPlaceholder": "可选,例如 cyan 或 #6366f1",
+ "colorHint": "选择子智能体卡片配色,用于已安装列表等展示。",
"bodyLabel": "指令内容",
"bodyPlaceholder": "用 Markdown 编写子智能体的人格与指令…",
"pleaseInputBody": "请输入指令内容",
@@ -689,6 +919,9 @@
"model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。"
},
"chat": {
+ "sharedExpert": {
+ "banner": "共享专家 · 由 {{name}} 提供"
+ },
"thinking": "正在思考",
"continuing": "继续生成中",
"generating": "生成中",
@@ -699,6 +932,11 @@
"refreshingMessages": "正在刷新消息…",
"streamResumed": "连接已恢复。若内容不完整,可下拉刷新。",
"regenerate": "重新生成",
+ "forkFromHere": "从此处分叉",
+ "forkSuccess": "已创建分叉对话",
+ "forkSuccessEmpty": "已创建新的空对话,请编辑问题后发送",
+ "forkFailed": "创建分叉失败",
+ "forkDisabledWhileBusy": "请等待当前回复或审批完成后再分叉",
"retry": "重试",
"like": "有帮助",
"dislike": "没帮助",
@@ -764,6 +1002,10 @@
"title": "专家详情"
},
"composerMore": "更多工具",
+ "knowledgePicker": "知识库",
+ "knowledgePickerSearch": "搜索知识库",
+ "knowledgePickerEmpty": "暂无可用知识库",
+ "manageKnowledgeBases": "管理知识库",
"skillPicker": "选择技能",
"skillPickerSearch": "搜索技能",
"skillPickerEmpty": "暂无可用技能",
@@ -822,7 +1064,8 @@
"read_file": "读取文件",
"write_file": "写入文件",
"edit_file": "编辑文件",
- "execute": "执行",
+ "execute": "执行指令",
+ "bash": "Shell 命令 (bash)",
"current_time": "当前时间",
"write_todos": "编写计划",
"task": "子智能体任务",
@@ -845,6 +1088,7 @@
"cronjob_update": "更新定时任务",
"cronjob_delete": "删除定时任务",
"cronjob_run_now": "立即执行定时任务",
+ "search_knowledge": "检索知识库",
"agent_list": "列出 Agent",
"ask_agent": "咨询 Agent",
"call_agent": "调用 Agent",
@@ -974,7 +1218,27 @@
"importConfirmOk": "恢复",
"importSuccess": "恢复完成({{agents}} 个 Agent,{{files}} 个工作区文件)",
"importFailed": "恢复备份失败",
- "restoreConfig": "同时恢复 config.json 与 env(需手动重启服务后生效)"
+ "restoreConfig": "同时恢复 config.json 与 env(需手动重启服务后生效)",
+ "autoTitle": "自动备份",
+ "autoDesc": "按计划将全量系统备份写入备份目录;保留策略只清理自动备份,不影响手动备份。",
+ "autoEnabled": "启用自动备份",
+ "autoSchedule": "备份周期",
+ "autoScheduleDaily": "每天 04:00",
+ "autoScheduleWeekly": "每周日 04:00",
+ "autoSchedule12h": "每 12 小时",
+ "autoScheduleCustom": "自定义",
+ "autoScheduleHint": "cron:分 时 日 月 周(服务器时区);interval:秒数,例如 interval:43200 表示每 12 小时。",
+ "autoIntervalPreview": "当前:每 {{seconds}} 秒(约 {{hours}} 小时)",
+ "autoRetention": "保留最近 N 份自动备份",
+ "autoSave": "保存设置",
+ "autoSaveSuccess": "自动备份设置已保存",
+ "autoSaveFailed": "保存自动备份设置失败",
+ "autoLoadFailed": "加载自动备份设置失败",
+ "autoRunNow": "立即备份",
+ "autoRunSuccess": "已创建自动备份",
+ "autoRunFailed": "立即备份失败",
+ "autoScheduled": "调度:已生效",
+ "autoNotScheduled": "调度:未生效"
},
"skills": {
"title": "技能",
@@ -992,6 +1256,8 @@
"invalidSkillUrlSource": "请输入有效的 HTTP(S) 技能 URL;具体来源由服务端适配器校验",
"zipHintTitle": "压缩包结构(每个一级文件夹 = 一个技能):",
"zipHintDetail": "每个文件夹内必须包含 SKILL.md,可附带脚本等其他文件。仅支持 .zip。",
+ "zipDragDropHint": "将需要上传的文件拖拽到此处",
+ "zipSelected": "已选择: {{name}}",
"chooseZip": "选择 ZIP 文件",
"noZipSelected": "未选择文件",
"removeZip": "移除",
@@ -1012,6 +1278,12 @@
"createSkill": "创建技能",
"viewSkill": "查看技能",
"editSkill": "编辑技能",
+ "fileTreeTitle": "技能文件",
+ "fileTreeEmpty": "暂无文件",
+ "fileTreeShow": "展开技能文件",
+ "fileTreeHide": "收起技能文件",
+ "fileTreeAgentNotReady": "智能体未运行,无法加载技能文件",
+ "finishEditBeforeSwitchFile": "请先保存或取消编辑,再切换文件",
"saveSkill": "保存",
"viewPreview": "预览",
"viewSource": "源码",
@@ -1100,6 +1372,8 @@
"addMetadata": "添加字段",
"metadataKeyRequired": "请输入 Key",
"metadataValueRequired": "请输入 Value",
+ "emojiLabel": "Emoji",
+ "emojiHint": "从列表中选择技能卡片图标。",
"bodyLabel": "技能实现内容",
"bodyPlaceholder": "说明技能的用途、触发条件与执行步骤(Markdown)",
"pleaseInputDescription": "请输入技能描述",
@@ -1385,6 +1659,10 @@
"enableChannel": "启用频道",
"enableChannelDesc": "关闭后频道将停止接收和发送消息,但保留配置",
"displaySettings": "消息展示",
+ "responseMode": "回复方式",
+ "responseModeDesc": "仅发送最终回复会隐藏工具调用前的过程说明;实时过程保留当前逐阶段消息",
+ "responseModeInvoke": "仅最终回复(推荐)",
+ "responseModeStream": "实时过程",
"showToolHints": "显示工具调用提示",
"showToolHintsDesc": "开启后,频道消息中将展示工具调用过程与状态提示",
"globalSettings": "全局设置",
@@ -1638,7 +1916,9 @@
"tencent-hai": "腾讯云 HAI",
"mimo": "小米 MiMo",
"minimax": "MiniMax",
- "volces": "火山引擎"
+ "volces": "火山引擎",
+ "ollama": "Ollama(本地)",
+ "onnx": "ONNX(本地)"
},
"voice": {
"loading": "加载语音配置…",
@@ -1697,6 +1977,11 @@
"hitlTools": "需审批的工具",
"hitlHint": "命中列表的工具在执行前会暂停,等待用户在对话中批准或拒绝。",
"hitlEnableWarning": "开启工具审批后,Agent 执行特定工具时将暂停并等待确认,这可能带来一些操作不便:在 IM 频道中需手动发送 /approve 或 /reject 指令才能继续;在 Dashboard 对话中会弹出审批卡片。请确认您愿意承担这些额外操作步骤。",
+ "hitlToolsPickerHint": "勾选需要在执行前人工审批的工具,下方为工具 id。",
+ "hitlToolsSelectDefaults": "推荐默认项",
+ "hitlToolsSelectAll": "全选",
+ "hitlToolsDeselectAll": "清空",
+ "hitlToolsEmpty": "暂无可配置审批的工具。",
"fsDesc": "禁止文件工具读写敏感路径。",
"fsEnable": "启用文件路径规则",
"fsPaths": "敏感路径(每行一条,支持 glob)",
@@ -1924,6 +2209,31 @@
"localDeleteFailed": "删除模型失败",
"localDownloadPending": "准备下载...",
"localDownloading": "正在下载 {{repo}}... 可能需要几分钟。",
+ "localDownloadConfirmTitle": "确认下载模型",
+ "localDownloadConfirmOnnx": "即将下载 {{name}}(约 {{size}})。下载可能需要几分钟,请保持网络畅通。",
+ "localDownloadConfirmOllama": "即将通过 Ollama 拉取 {{name}}。下载体积取决于模型,可能需要较长时间。",
+ "localDownloadSizeUnknown": "大小未知",
+ "localDownloadProgressTitle": "正在下载模型",
+ "localDownloadPreparing": "准备下载…",
+ "localDownloadContinueBackground": "后台继续",
+ "localDownloadBackground": "下载已转到后台,完成后会通知你",
+ "localDownloadBackgroundHint": "可关闭此窗口,下载在服务端继续,完成后会弹出通知。",
+ "onnxDownloadProgress": "正在下载 {{model}}({{percent}}%)",
+ "onnxDownloadLoading": "正在加载 {{model}}…",
+ "defaultModelDownloadedOnly": "仅可选择已下载的模型",
+ "defaultModelNeedDownload": "请先在下方管理模型中下载",
+ "downloadBeforeEnable": "请先下载模型后再启用",
+ "notDownloaded": "未下载",
+ "localModelDownloaded": "已下载",
+ "localServiceHint": "控制本地服务是否启动",
+ "localServiceLabel": "服务",
+ "localRuntime": "本地运行时",
+ "localServiceRunning": "服务运行中",
+ "localServiceOn": "服务已开启",
+ "localServiceOff": "服务已关闭",
+ "localServiceStarted": "本地服务已启动",
+ "localServiceStopped": "本地服务已停止",
+ "localServiceToggleFailed": "切换本地服务失败",
"localCancelDownload": "取消下载",
"localCancelDownloadConfirm": "确定取消下载 \"{{repo}}\"?",
"localDownloadCancelled": "下载已取消",
@@ -2066,7 +2376,43 @@
"fetchModelsFailed": "获取模型失败:{{error}}",
"fetchModelsUnsupportedKind": "仅 OpenAI 兼容供应商支持自动获取模型列表",
"customModelsLabel": "模型",
- "customModelsHint": "获取后默认未启用,可开启开关、测试、编辑或删除;也可手动添加"
+ "customModelsHint": "获取后默认未启用,可开启开关、测试、编辑或删除;也可手动添加",
+ "onnxLocalService": "本地 ONNX Embedding",
+ "onnxServiceHint": "本机 ONNX Embedding 模型缓存(下载 / 启用 / 探测)。不是对话模型,也不会接入 Memory。",
+ "onnxNoProviderForm": "仅本地缓存:打开开关、从目录选模型并下载权重。不用于对话,也不用于 Memory。",
+ "onnxModelLabel": "模型",
+ "onnxModelPlaceholder": "从目录选择模型",
+ "onnxApply": "应用",
+ "onnxRecommended": "推荐",
+ "onnxDepsPending": "本地嵌入组件尚未就绪,开启服务时将自动安装。",
+ "onnxDepsInstallFailed": "自动安装失败,请检查网络连接后重试。",
+ "onnxInstallingDeps": "正在安装 ONNX 本地依赖,可能需要几分钟…",
+ "onnxDepsInstalled": "ONNX 本地依赖已安装",
+ "enableAfterDownloadFailed": "模型已下载,但自动启用失败,请手动开启后保存。",
+ "embeddingOnlyTag": "仅 Embedding(不进聊天/Auto)",
+ "embeddingModel": "向量模型",
+ "embeddingModelHint": "开启后仅用于知识库等向量检索,不会出现在聊天与 Auto 列表中。",
+ "onnxTestNeedDownload": "请先下载模型后再测试",
+ "onnxCached": "已缓存",
+ "onnxSelectModel": "请先选择或输入模型 ID",
+ "onnxEnabled": "已开启本地 ONNX 服务",
+ "onnxDisabled": "已关闭本地 ONNX 服务",
+ "onnxModelApplied": "已选择模型 {{model}}",
+ "onnxDownloadStarted": "正在下载 {{model}}…",
+ "onnxDownloadDone": "已下载 {{model}}",
+ "onnxDownloadFailed": "ONNX 模型下载失败",
+ "onnxLoadFailed": "加载 ONNX 服务状态失败",
+ "onnxSaveFailed": "更新 ONNX 服务失败",
+ "onnxDeleteFailed": "删除本地缓存失败",
+ "onnxModelDeleted": "已删除缓存模型 {{model}}",
+ "onnxLoading": "正在加载 {{model}}…",
+ "onnxDownloading": "正在下载 {{model}}…",
+ "onnxStatusLine": "状态:{{ready}} · 模型 {{model}}",
+ "onnxReady": "就绪",
+ "onnxNotReady": "未就绪",
+ "onnxCacheDir": "缓存目录:{{dir}}",
+ "onnxLocalCached": "已缓存模型",
+ "onnxQuickDownload": "从目录快速下载…"
},
"advancedSettings": {
"description": "管理运行配置和环境变量等高级选项。",
@@ -2966,9 +3312,9 @@
"installSuccess": "浏览器安装成功",
"installSuccessHint": "浏览器已就绪,可启动会话",
"installFailed": "安装失败",
- "installFailedHint": "可重试或手动运行 playwright install chromium",
+ "installFailedHint": "自动安装失败,请重试。若网络不稳定,可配置 PLAYWRIGHT_DOWNLOAD_HOST 镜像源后再次安装。",
"notInstalled": "Chromium 未安装",
- "notInstalledHint": "运行自动安装或手动执行 playwright install chromium。",
+ "notInstalledHint": "尚未检测到可用浏览器,请点击下方按钮自动安装内置 Chromium。",
"install": "安装浏览器",
"installProgress": "正在安装中…",
"installCancelHint": "已取消安装请求,服务端可能仍在继续安装,请稍后刷新状态。",
@@ -3554,7 +3900,26 @@
"submit": "登录",
"failed": "登录失败",
"slideHint": "拖动滑块到最右侧完成验证",
- "slideVerified": "验证通过"
+ "slideVerified": "验证通过",
+ "or": "或",
+ "oidcWith": "使用 {{name}} 继续",
+ "oidcStartFailed": "无法启动单点登录",
+ "oidcError": {
+ "denied": "已取消单点登录。",
+ "state": "单点登录会话已过期,请重试。",
+ "disabled": "单点登录不可用。",
+ "misconfigured": "单点登录配置有误。",
+ "invalid_token": "身份提供商返回了无效令牌。",
+ "exchange": "无法验证身份提供商登录。",
+ "generic": "单点登录失败,请重试。"
+ },
+ "oidcComplete": {
+ "title": "正在完成单点登录",
+ "loading": "正在登录…",
+ "missingCode": "单点登录响应缺少登录代码。",
+ "failed": "无法完成单点登录",
+ "backToLogin": "返回登录"
+ }
},
"adminUsers": {
"newUser": "新建用户",
@@ -3582,6 +3947,8 @@
"colId": "ID",
"colUsername": "用户名",
"colDisplayName": "显示名称",
+ "colEmail": "邮箱",
+ "colAuth": "登录方式",
"colCreatedAt": "创建时间",
"colRole": "角色",
"colLoginLock": "登录锁定",
@@ -3608,7 +3975,57 @@
"noUsers": "暂无用户",
"totalUsers": "共 {{count}} 个用户",
"statusEnabled": "已启用",
- "statusDisabled": "已禁用"
+ "statusDisabled": "已禁用",
+ "ssoBadge": "单点登录",
+ "passwordBadge": "密码",
+ "tabLocal": "内置用户",
+ "tabSso": "单点登录",
+ "colPermissions": "权限",
+ "permAll": "全部(管理员)",
+ "permAllHint": "管理员自动拥有全部模块权限,无需单独勾选。",
+ "permEditHint": "按模块授予管理页面与写入权限",
+ "permGroupSettings": "设置",
+ "permGroupControl": "控制",
+ "permGroupAdmin": "管理",
+ "permCatalogEmpty": "暂无可用权限项",
+ "permCount": "{{count}} 项",
+ "createSectionAccount": "账号信息",
+ "createSectionAccess": "角色与权限",
+ "modalEditTitle": "编辑用户 {{username}}"
+ },
+ "adminSso": {
+ "enabled": "启用单点登录",
+ "enabledHint": "允许用户通过已配置的 OpenID Connect 身份提供商登录。",
+ "displayName": "提供商显示名称",
+ "displayNameRequired": "请输入提供商显示名称",
+ "issuer": "签发者 URL",
+ "issuerHint": "身份提供商公开的 OpenID Connect 签发者 URL。",
+ "issuerRequired": "请输入有效的签发者 URL",
+ "clientId": "客户端 ID",
+ "clientIdRequired": "请输入客户端 ID",
+ "clientSecret": "客户端密钥",
+ "clientSecretHint": "公共客户端可不填写。",
+ "clientSecretConfigured": "已配置客户端密钥;留空将保留现有密钥。",
+ "clientSecretPlaceholder": "留空以保留现有密钥",
+ "scopes": "授权范围",
+ "scopesRequired": "请至少输入一个授权范围",
+ "dashboardOrigin": "控制台来源地址覆盖",
+ "dashboardOriginHint": "可选。身份提供商回调后使用的公开控制台 URL。",
+ "dashboardOriginInvalid": "请输入有效的控制台来源地址 URL",
+ "redirectUri": "回调地址",
+ "redirectUriHint": "请将此精确回调 URL 添加到身份提供商配置中。",
+ "copy": "复制",
+ "copyRedirectUri": "复制回调地址",
+ "copySuccess": "回调地址已复制",
+ "copyFailed": "无法复制回调地址",
+ "save": "保存",
+ "saved": "单点登录设置已保存",
+ "saveFailed": "无法保存单点登录设置",
+ "loadFailed": "无法加载单点登录设置",
+ "testConnection": "测试连接",
+ "testSuccess": "OIDC 连接成功",
+ "testFailed": "OIDC 连接测试失败",
+ "testHint": "更改签发者或客户端 ID 后,请先保存再测试。"
},
"adminAudit": {
"loadFailed": "加载失败",
@@ -3774,10 +4191,10 @@
"addConnection": "添加 {{name}}",
"displayName": "显示名称",
"defaultOpen": "是否默认打开",
- "defaultOpenHint": "关闭时需在对话中手动勾选才会注入工具。",
- "defaultOpenWarning": "开启后默认会在 Dashboard、IM 与 Cron(未特殊选连接器时)携带该工具(额外消耗 token)。Dashboard 可关本轮;Cron 若显式选择连接器则以选择为准。",
+ "defaultOpenHint": "仅对你自己的账号生效:关闭时需在对话中手动勾选才会注入工具。",
+ "defaultOpenWarning": "开启后默认会在你的 Dashboard、IM 与 Cron(未特殊选连接器时)携带该工具(额外消耗 token)。Dashboard 可关本轮;Cron 若显式选择连接器则以选择为准。",
"defaultOpenLockedBadge": "默认打开",
- "defaultOpenLockedHint": "连接器已设为默认打开。IM 与未选手动连接器的 Cron 会自动携带;Dashboard / 已选手动的 Cron 以用户选择为准。",
+ "defaultOpenLockedHint": "连接器已设为默认打开(仅你自己的对话)。IM 与未选手动连接器的 Cron 会自动携带;Dashboard / 已选手动的 Cron 以选择为准。",
"token": "访问 Token",
"getToken": "获取 Token",
"goToAuthorize": "前往授权",
@@ -3987,7 +4404,7 @@
"subtitle": "Agent 的本地文件系统工作空间"
},
"models": {
- "title": "模型管理",
+ "title": "模型配置",
"subtitle": "配置 LLM 提供商和 API 密钥"
},
"voice": {
@@ -3996,7 +4413,11 @@
},
"adminUsers": {
"title": "用户管理",
- "subtitle": "创建与管理账户,并区分管理员与普通用户的权限范围"
+ "subtitle": "管理内置账号与单点登录"
+ },
+ "adminSso": {
+ "title": "单点登录",
+ "subtitle": "配置 OpenID Connect 身份提供商"
},
"adminStorage": {
"title": "存储管理",
diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx
new file mode 100644
index 00000000..93cbc50a
--- /dev/null
+++ b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx
@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import { I18nextProvider } from "react-i18next";
+import i18n from "../../../i18n";
+
+const { getOidcConfig, putOidcConfig, testOidcConfig } = vi.hoisted(() => ({
+ getOidcConfig: vi.fn(),
+ putOidcConfig: vi.fn(),
+ testOidcConfig: vi.fn(),
+}));
+
+vi.mock("../../../api/modules/sso", () => ({
+ ssoApi: { getOidcConfig, putOidcConfig, testOidcConfig },
+}));
+
+vi.mock("@/utils/antdMessage", () => ({
+ message: { error: vi.fn(), success: vi.fn() },
+}));
+
+import SsoPanel from "./SsoPanel";
+
+describe("", () => {
+ it("loads the provider configuration and displays its callback URL", async () => {
+ getOidcConfig.mockResolvedValue({
+ enabled: true,
+ display_name: "Acme SSO",
+ issuer: "https://identity.example.com",
+ client_id: "octop",
+ scopes: "openid profile email",
+ dashboard_origin: "https://octop.example.com",
+ has_client_secret: true,
+ redirect_uri: "https://octop.example.com/api/auth/oidc/callback",
+ });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(getOidcConfig).toHaveBeenCalledOnce());
+ expect(screen.getByDisplayValue("Acme SSO")).toBeInTheDocument();
+ expect(
+ screen.getByDisplayValue(
+ "https://octop.example.com/api/auth/oidc/callback",
+ ),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.tsx
new file mode 100644
index 00000000..cb1006c5
--- /dev/null
+++ b/dashboard/src/pages/Admin/Users/SsoPanel.tsx
@@ -0,0 +1,230 @@
+import { useCallback, useEffect, useState } from "react";
+import { Button, Form, Input, Space, Spin, Switch, Typography } from "antd";
+import { Copy, FlaskConical, Save } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { message } from "@/utils/antdMessage";
+import {
+ ssoApi,
+ type OidcConfig,
+ type OidcConfigPut,
+} from "../../../api/modules/sso";
+import { apiErrorMessage } from "../../../utils/apiError";
+
+interface SsoFormValues {
+ enabled: boolean;
+ display_name: string;
+ issuer: string;
+ client_id: string;
+ client_secret?: string;
+ scopes: string;
+ dashboard_origin?: string;
+}
+
+function configToFormValues(config: OidcConfig): SsoFormValues {
+ return {
+ enabled: config.enabled,
+ display_name: config.display_name,
+ issuer: config.issuer,
+ client_id: config.client_id,
+ scopes: config.scopes,
+ dashboard_origin: config.dashboard_origin ?? "",
+ };
+}
+
+export default function SsoPanel() {
+ const { t } = useTranslation();
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [redirectUri, setRedirectUri] = useState("");
+ const [hasClientSecret, setHasClientSecret] = useState(false);
+
+ const loadConfig = useCallback(async () => {
+ setLoading(true);
+ try {
+ const config = await ssoApi.getOidcConfig();
+ form.setFieldsValue(configToFormValues(config));
+ setRedirectUri(config.redirect_uri ?? "");
+ setHasClientSecret(config.has_client_secret);
+ } catch (error) {
+ message.error(apiErrorMessage(error, t("adminSso.loadFailed"), t));
+ } finally {
+ setLoading(false);
+ }
+ }, [form, t]);
+
+ useEffect(() => {
+ void loadConfig();
+ }, [loadConfig]);
+
+ const saveConfig = async (values: SsoFormValues) => {
+ setSaving(true);
+ try {
+ const body: OidcConfigPut = {
+ ...values,
+ client_secret: values.client_secret?.trim() || undefined,
+ dashboard_origin: values.dashboard_origin?.trim() || null,
+ };
+ const saved = await ssoApi.putOidcConfig(body);
+ form.setFieldsValue(configToFormValues(saved));
+ form.setFieldValue("client_secret", undefined);
+ setHasClientSecret(saved.has_client_secret);
+ if (saved.redirect_uri) {
+ setRedirectUri(saved.redirect_uri);
+ }
+ message.success(t("adminSso.saved"));
+ } catch (error) {
+ message.error(apiErrorMessage(error, t("adminSso.saveFailed"), t));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const testConnection = async () => {
+ setTesting(true);
+ try {
+ const result = await ssoApi.testOidcConfig();
+ if (result.ok) {
+ message.success(result.detail || t("adminSso.testSuccess"));
+ } else {
+ message.error(result.detail || t("adminSso.testFailed"));
+ }
+ } catch (error) {
+ message.error(apiErrorMessage(error, t("adminSso.testFailed"), t));
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ const copyRedirectUri = async () => {
+ try {
+ await navigator.clipboard.writeText(redirectUri);
+ message.success(t("adminSso.copySuccess"));
+ } catch {
+ message.error(t("adminSso.copyFailed"));
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={() => void copyRedirectUri()}
+ aria-label={t("adminSso.copyRedirectUri")}
+ >
+ {t("adminSso.copy")}
+
+
+
+
+ }
+ loading={saving}
+ >
+ {t("adminSso.save")}
+
+ }
+ loading={testing}
+ onClick={() => void testConnection()}
+ >
+ {t("adminSso.testConnection")}
+
+
+
+ {t("adminSso.testHint")}
+
+
+
+ );
+}
diff --git a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx
new file mode 100644
index 00000000..f77ab732
--- /dev/null
+++ b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx
@@ -0,0 +1,1715 @@
+/**
+ * Admin → Users page (plan §14.7).
+ *
+ * List all users with role/disabled toggles, password reset, delete.
+ * Card and table views; default is card on mobile, table on desktop. The view switcher + refresh +
+ * new-user buttons live in a content-area toolbar (mirrors the Experts
+ * page layout). Each row/card shows agent count; click opens a drawer
+ * with that user's agents.
+ *
+ * Endpoints (all require admin role; backend returns 403 otherwise):
+ * GET /api/users
+ * POST /api/users
+ * PATCH /api/users/{id}
+ * POST /api/users/{id}/reset-password
+ * DELETE /api/users/{id}
+ */
+
+import { useEffect, useMemo, useState, useCallback } from "react";
+import {
+ Table,
+ Button,
+ Modal,
+ Form,
+ Input,
+ Space,
+ Popconfirm,
+ Switch,
+ Typography,
+ Tooltip,
+ Drawer,
+ Empty,
+ Spin,
+ Tag,
+ Segmented,
+ Checkbox,
+} from "antd";
+import { message } from "@/utils/antdMessage";
+
+import {
+ Bot,
+ Check,
+ ChevronRight,
+ Clock,
+ IdCard,
+ KeyRound,
+ LayoutGrid,
+ List,
+ Lock,
+ LockOpen,
+ Pencil,
+ Plus,
+ RefreshCw,
+ Search,
+ ShieldCheck,
+ Trash2,
+ User,
+ UserRound,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { request } from "../../../api/request";
+import { authApi } from "../../../api/modules/auth";
+import { useCardTableView } from "../../../hooks/useCardTableView";
+import { useServerTimezone } from "../../../hooks/useServerTimezone";
+import { formatServerDateTime } from "../../../utils/formatMessageTime";
+import type { OctopAgent } from "../../../context/AgentContext";
+import { AgentCard } from "../../Experts/components/AgentCard";
+import EditAgentDrawer from "../../Experts/components/EditAgentDrawer";
+import expertStyles from "../../Experts/index.module.less";
+import styles from "./index.module.less";
+
+const { Text } = Typography;
+
+interface UserRow {
+ id: number;
+ username: string;
+ role: "admin" | "user";
+ display_name: string | null;
+ email?: string | null;
+ has_password?: boolean;
+ sso_linked?: boolean;
+ disabled: boolean;
+ login_failed_count?: number;
+ login_locked?: boolean;
+ login_locked_until?: number;
+ login_retry_after_seconds?: number;
+ created_at?: number;
+ permissions?: string[];
+}
+
+interface PermissionCatalogItem {
+ key: string;
+ category: string;
+ label: string;
+ page?: string;
+ page_label?: string;
+}
+
+function permFullLabel(item: PermissionCatalogItem): string {
+ if (item.page_label) return `${item.page_label} / ${item.label}`;
+ return item.label;
+}
+
+interface CreateValues {
+ username: string;
+ display_name?: string;
+ password: string;
+ confirm: string;
+ role: "admin" | "user";
+ permissions?: string[];
+}
+
+interface EditValues {
+ role: "admin" | "user";
+ permissions?: string[];
+}
+
+interface ResetValues {
+ password: string;
+ confirm: string;
+}
+
+function roleToneClass(role: "admin" | "user"): string {
+ return role === "admin" ? styles.roleToneAdmin : styles.roleToneUser;
+}
+
+function useNowSeconds(active: boolean): number {
+ const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
+ useEffect(() => {
+ if (!active) return;
+ const id = window.setInterval(
+ () => setNow(Math.floor(Date.now() / 1000)),
+ 1000,
+ );
+ return () => window.clearInterval(id);
+ }, [active]);
+ return now;
+}
+
+function lockRemainingSeconds(row: UserRow, nowSec: number): number {
+ if (!row.login_locked || !row.login_locked_until) return 0;
+ return Math.max(0, row.login_locked_until - nowSec);
+}
+
+function formatUserTs(ts: number | undefined, timeZone: string): string {
+ if (!ts) return "—";
+ return formatServerDateTime(ts, timeZone);
+}
+
+interface UserCardGridProps {
+ rows: UserRow[];
+ loading: boolean;
+ agentsByUserId: Map;
+ agentsLoading: boolean;
+ currentUserId: number | null;
+ permLabelByKey: Map;
+ onTogglePatch: (
+ row: UserRow,
+ patch: Partial>,
+ ) => Promise;
+ onEdit: (row: UserRow) => void;
+ onShowAgents: (row: UserRow) => void;
+ onResetPassword: (row: UserRow) => void;
+ onDelete: (row: UserRow) => Promise;
+ onUnlockLogin: (row: UserRow) => Promise;
+ nowSec: number;
+}
+
+function userInitials(displayName: string, username: string): string {
+ const source = displayName.trim() || username;
+ const parts = source.split(/[\s._-]+/).filter(Boolean);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return source.slice(0, 2).toUpperCase();
+}
+
+const FIELD_ICON_PROPS = {
+ size: 16 as const,
+ style: { color: "var(--fn-text-tertiary)" },
+};
+
+interface RolePickerProps {
+ value?: "admin" | "user";
+ onChange?: (value: "admin" | "user") => void;
+ disabled?: boolean;
+ options: {
+ value: "admin" | "user";
+ label: string;
+ hint: string;
+ }[];
+}
+
+function RolePicker({ value, onChange, options, disabled }: RolePickerProps) {
+ return (
+
+ {options.map((opt) => {
+ const selected = value === opt.value;
+ const Icon = opt.value === "admin" ? ShieldCheck : UserRound;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+interface PermissionCheckboxPickerProps {
+ value?: string[];
+ onChange?: (value: string[]) => void;
+ catalog: PermissionCatalogItem[];
+ disabled?: boolean;
+}
+
+function PermissionCheckboxPicker({
+ value,
+ onChange,
+ catalog,
+ disabled,
+}: PermissionCheckboxPickerProps) {
+ const { t } = useTranslation();
+ const selected = value ?? [];
+ const selectedSet = useMemo(() => new Set(selected), [selected]);
+
+ const groups = useMemo(() => {
+ const order = [
+ {
+ category: "settings",
+ label: t("adminUsers.permGroupSettings"),
+ },
+ {
+ category: "control",
+ label: t("adminUsers.permGroupControl"),
+ },
+ {
+ category: "admin",
+ label: t("adminUsers.permGroupAdmin"),
+ },
+ ] as const;
+ return order
+ .map((g) => {
+ const items = catalog.filter((p) => p.category === g.category);
+ const pages: {
+ page: string;
+ label: string;
+ items: PermissionCatalogItem[];
+ }[] = [];
+ const standalone: PermissionCatalogItem[] = [];
+ for (const item of items) {
+ if (!item.page) {
+ standalone.push(item);
+ continue;
+ }
+ const existing = pages.find((p) => p.page === item.page);
+ if (existing) {
+ existing.items.push(item);
+ } else {
+ pages.push({
+ page: item.page,
+ label: item.page_label || item.page,
+ items: [item],
+ });
+ }
+ }
+ return { ...g, items, standalone, pages };
+ })
+ .filter((g) => g.items.length > 0);
+ }, [catalog, t]);
+
+ const toggle = (key: string, checked: boolean) => {
+ if (disabled) return;
+ if (checked) {
+ onChange?.([...selected, key]);
+ return;
+ }
+ onChange?.(selected.filter((k) => k !== key));
+ };
+
+ const setGroup = (keys: string[], checked: boolean) => {
+ if (disabled) return;
+ if (checked) {
+ const next = new Set(selected);
+ for (const k of keys) next.add(k);
+ onChange?.(Array.from(next));
+ return;
+ }
+ const drop = new Set(keys);
+ onChange?.(selected.filter((k) => !drop.has(k)));
+ };
+
+ if (catalog.length === 0) {
+ return (
+
+ {t("adminUsers.permCatalogEmpty")}
+
+ );
+ }
+
+ return (
+
+ {groups.map((group) => {
+ const keys = group.items.map((i) => i.key);
+ const checkedCount = keys.filter((k) => selectedSet.has(k)).length;
+ const allChecked = checkedCount === keys.length && keys.length > 0;
+ const indeterminate = checkedCount > 0 && !allChecked;
+ const renderChips = (items: PermissionCatalogItem[]) => (
+
+ {items.map((item) => {
+ const checked = selectedSet.has(item.key);
+ return (
+
+ );
+ })}
+
+ );
+ return (
+
+
+ setGroup(keys, e.target.checked)}
+ >
+ {group.label}
+
+
+ {checkedCount}/{keys.length}
+
+
+ {group.pages.length === 0 ? (
+ renderChips(group.items)
+ ) : (
+ <>
+ {group.standalone.length > 0
+ ? renderChips(group.standalone)
+ : null}
+ {group.pages.map((page) => {
+ const pageKeys = page.items.map((i) => i.key);
+ const pageChecked = pageKeys.filter((k) =>
+ selectedSet.has(k),
+ ).length;
+ const pageAll =
+ pageChecked === pageKeys.length && pageKeys.length > 0;
+ const pageIndeterminate = pageChecked > 0 && !pageAll;
+ return (
+
+
+ setGroup(pageKeys, e.target.checked)}
+ >
+
+ {page.label}
+
+
+
+ {pageChecked}/{pageKeys.length}
+
+
+ {renderChips(page.items)}
+
+ );
+ })}
+ >
+ )}
+
+ );
+ })}
+
+ );
+}
+
+function PermissionSummary({
+ row,
+ permLabelByKey,
+}: {
+ row: UserRow;
+ permLabelByKey: Map;
+}) {
+ const { t } = useTranslation();
+ if (row.role === "admin") {
+ return (
+
+ {t("adminUsers.permAll")}
+
+ );
+ }
+ const keys = row.permissions ?? [];
+ if (keys.length === 0) {
+ return —;
+ }
+ const names = keys.map((key) => permLabelByKey.get(key) ?? key);
+ return (
+
+
+ {t("adminUsers.permCount", { count: keys.length })}
+
+
+ );
+}
+
+function RoleLegend() {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("adminUsers.roleLegendTitle")}
+
+
{t("adminUsers.roleLegend")}
+
+ );
+}
+
+function UserCardGrid({
+ rows,
+ loading,
+ agentsByUserId,
+ agentsLoading,
+ currentUserId,
+ permLabelByKey,
+ onTogglePatch,
+ onEdit,
+ onShowAgents,
+ onResetPassword,
+ onDelete,
+ onUnlockLogin,
+ nowSec,
+}: UserCardGridProps) {
+ const { t } = useTranslation();
+ const timeZone = useServerTimezone();
+ if (loading && rows.length === 0) {
+ return (
+
+
+
+ );
+ }
+ if (rows.length === 0) {
+ return ;
+ }
+ return (
+
+ {rows.map((row) => {
+ const agentCount = agentsByUserId.get(row.id)?.length ?? 0;
+ const isSelf = row.id === currentUserId;
+ const displayName = row.display_name?.trim() || row.username;
+ const remaining = lockRemainingSeconds(row, nowSec);
+ const isLocked = remaining > 0;
+ const failedCount = row.login_failed_count ?? 0;
+ const accentClass = isLocked
+ ? styles.userCardAccentLocked
+ : row.disabled
+ ? styles.userCardAccentDisabled
+ : row.role === "admin"
+ ? styles.userCardAccentAdmin
+ : styles.userCardAccentUser;
+ const statusColor = row.disabled ? "#8c8c8c" : "#52c41a";
+ const statusBg = row.disabled
+ ? "rgba(140,140,140,0.10)"
+ : "rgba(82,196,26,0.10)";
+ return (
+
+
+
+
+
+
+ {userInitials(displayName, row.username)}
+
+
+
+
+ {displayName}
+ {isSelf && (
+
+ {t("adminUsers.you")}
+
+ )}
+
+
@{row.username}
+
+
+
+ void onTogglePatch(row, { disabled: !checked })
+ }
+ className={styles.userCardSwitch}
+ aria-label={t("common.enabled")}
+ />
+
+
+
+
+ {row.role === "admin"
+ ? t("adminUsers.roleAdmin")
+ : t("adminUsers.roleUser")}
+
+
+
+ {row.disabled
+ ? t("adminUsers.statusDisabled")
+ : t("adminUsers.statusEnabled")}
+
+ {row.sso_linked && (
+
+ {t("adminUsers.ssoBadge")}
+
+ )}
+ {row.has_password && (
+
+ {t("adminUsers.passwordBadge")}
+
+ )}
+
+
+
+ {row.created_at != null && (
+
+
+
+ {formatUserTs(row.created_at, timeZone)}
+
+
+ )}
+
+
+
+
+
+
+
+ {isLocked && (
+
+
+
+ {t("adminUsers.loginLockActive", {
+ minutes: Math.max(1, Math.ceil(remaining / 60)),
+ })}
+
+
+
+ )}
+
+ {!isLocked && failedCount > 0 && (
+
+ {t("adminUsers.loginFailedCount", { count: failedCount })}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
void onDelete(row)}
+ disabled={isSelf}
+ >
+
+
+
+
+
+
+
+
#{row.id}
+
+
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Compact login-lock status indicator. Used by both the card view
+ * (inline in a `userCard2Row`) and the table view (table cell).
+ */
+function UserLoginLock({
+ row,
+ nowSec,
+ onUnlock,
+}: {
+ row: UserRow;
+ nowSec: number;
+ onUnlock: () => void;
+}) {
+ const { t } = useTranslation();
+ const failedCount = row.login_failed_count ?? 0;
+ if (!row.login_locked) {
+ if (failedCount > 0) {
+ return (
+
+ {t("adminUsers.loginFailedCount", { count: failedCount })}
+
+ );
+ }
+ return (
+
+ {t("adminUsers.loginLockNone")}
+
+ );
+ }
+ const remaining = lockRemainingSeconds(row, nowSec);
+ const minutes = Math.max(1, Math.ceil(remaining / 60));
+ return (
+
+
+ {t("adminUsers.loginLockActive", { minutes })}
+
+
+
+ );
+}
+
+export default function UsersListPanel() {
+ const { t } = useTranslation();
+ const timeZone = useServerTimezone();
+ const [agents, setAgents] = useState([]);
+ const [agentsLoading, setAgentsLoading] = useState(true);
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [form] = Form.useForm();
+ const [editTarget, setEditTarget] = useState(null);
+ const [editSubmitting, setEditSubmitting] = useState(false);
+ const [editForm] = Form.useForm();
+ const [resetTarget, setResetTarget] = useState(null);
+ const [resetSubmitting, setResetSubmitting] = useState(false);
+ const [resetForm] = Form.useForm();
+ const [currentUserId, setCurrentUserId] = useState(null);
+ const [agentDrawerUser, setAgentDrawerUser] = useState(null);
+ const [editAgent, setEditAgent] = useState(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const { viewMode, setViewMode, showCardView } = useCardTableView("table");
+ const [permCatalog, setPermCatalog] = useState([]);
+
+ const permLabelByKey = useMemo(() => {
+ const map = new Map();
+ for (const item of permCatalog) {
+ map.set(item.key, permFullLabel(item));
+ }
+ return map;
+ }, [permCatalog]);
+
+ const baselinePermissions = useMemo(
+ () =>
+ permCatalog.filter((p) => p.category === "settings").map((p) => p.key),
+ [permCatalog],
+ );
+
+ const createRoleOptions = useMemo(
+ () => [
+ {
+ value: "user" as const,
+ label: t("adminUsers.roleUser"),
+ hint: t("adminUsers.roleUserHint"),
+ },
+ {
+ value: "admin" as const,
+ label: t("adminUsers.roleAdmin"),
+ hint: t("adminUsers.roleAdminHint"),
+ },
+ ],
+ [t],
+ );
+
+ const isSelfAdmin = useCallback(
+ (row: UserRow) => row.id === currentUserId && row.role === "admin",
+ [currentUserId],
+ );
+
+ const hasLockedUser = useMemo(
+ () => rows.some((row) => row.login_locked),
+ [rows],
+ );
+ const nowSec = useNowSeconds(hasLockedUser);
+
+ const agentsByUserId = useMemo(() => {
+ const map = new Map();
+ for (const agent of agents) {
+ if (agent.user_id == null) continue;
+ const list = map.get(agent.user_id) ?? [];
+ list.push(agent);
+ map.set(agent.user_id, list);
+ }
+ return map;
+ }, [agents]);
+
+ const drawerAgents = agentDrawerUser
+ ? agentsByUserId.get(agentDrawerUser.id) ?? []
+ : [];
+
+ const filteredRows = useMemo(() => {
+ const query = searchQuery.trim().toLowerCase();
+ if (!query) return rows;
+ return rows.filter((row) => {
+ const username = row.username.toLowerCase();
+ const displayName = (row.display_name ?? "").trim().toLowerCase();
+ return username.includes(query) || displayName.includes(query);
+ });
+ }, [rows, searchQuery]);
+
+ const refreshUsers = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await request("/users");
+ setRows(data);
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : t("adminUsers.loadFailed"),
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [t]);
+
+ useEffect(() => {
+ if (!hasLockedUser) return;
+ const anyExpired = rows.some(
+ (row) => row.login_locked && lockRemainingSeconds(row, nowSec) === 0,
+ );
+ if (anyExpired) void refreshUsers();
+ }, [hasLockedUser, nowSec, rows, refreshUsers]);
+
+ const refreshAgents = useCallback(async () => {
+ setAgentsLoading(true);
+ try {
+ const data = await request("/agents?scope=all");
+ setAgents(data);
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : t("adminUsers.loadFailed"),
+ );
+ setAgents([]);
+ } finally {
+ setAgentsLoading(false);
+ }
+ }, [t]);
+
+ const patchAgent = useCallback(
+ (agentId: string, patch: Partial) => {
+ setAgents((prev) =>
+ prev.map((a) => (a.agent_id === agentId ? { ...a, ...patch } : a)),
+ );
+ },
+ [],
+ );
+
+ const handleDrawerStateChange = useCallback(
+ (agentId: string, newState: string) => {
+ patchAgent(agentId, { state: newState });
+ },
+ [patchAgent],
+ );
+
+ const handleDrawerDeleted = useCallback(
+ (agentId: string) => {
+ setAgents((prev) => prev.filter((a) => a.agent_id !== agentId));
+ void refreshAgents();
+ },
+ [refreshAgents],
+ );
+
+ const handleEditSaved = useCallback(
+ (
+ updated: Pick<
+ OctopAgent,
+ "agent_id" | "name" | "description" | "default_model"
+ >,
+ ) => {
+ setEditAgent(null);
+ patchAgent(updated.agent_id, {
+ name: updated.name,
+ description: updated.description,
+ default_model: updated.default_model,
+ });
+ void refreshAgents();
+ },
+ [patchAgent, refreshAgents],
+ );
+
+ const refreshAll = useCallback(async () => {
+ await Promise.all([refreshUsers(), refreshAgents()]);
+ }, [refreshUsers, refreshAgents]);
+
+ useEffect(() => {
+ void refreshAll();
+ authApi
+ .me()
+ .then((u) => setCurrentUserId(u.id))
+ .catch(() => setCurrentUserId(null));
+ request("/users/permissions")
+ .then(setPermCatalog)
+ .catch(() => setPermCatalog([]));
+ }, [refreshAll]);
+
+ const onCreate = async (values: CreateValues) => {
+ setSubmitting(true);
+ try {
+ await request("/users", {
+ method: "POST",
+ body: JSON.stringify({
+ username: values.username,
+ display_name: values.display_name?.trim() || null,
+ password: values.password,
+ role: values.role,
+ permissions: values.role === "admin" ? [] : values.permissions ?? [],
+ }),
+ });
+ message.success(
+ t("adminUsers.createSuccess", { username: values.username }),
+ );
+ form.resetFields();
+ setCreateOpen(false);
+ void refreshUsers();
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : t("adminUsers.createFailed"),
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const openCreate = () => {
+ form.setFieldsValue({
+ role: "user",
+ permissions: [...baselinePermissions],
+ username: undefined,
+ display_name: undefined,
+ password: undefined,
+ confirm: undefined,
+ });
+ setCreateOpen(true);
+ };
+
+ const openEdit = (row: UserRow) => {
+ setEditTarget(row);
+ editForm.setFieldsValue({
+ role: row.role,
+ permissions: [...(row.permissions ?? [])],
+ });
+ };
+
+ const togglePatch = async (
+ row: UserRow,
+ patch: Partial>,
+ ): Promise => {
+ if (
+ patch.role === "user" &&
+ row.id === currentUserId &&
+ row.role === "admin"
+ ) {
+ message.warning(t("adminUsers.demoteSelf"));
+ return false;
+ }
+ try {
+ await request(`/users/${row.id}`, {
+ method: "PATCH",
+ body: JSON.stringify(patch),
+ });
+ void refreshUsers();
+ return true;
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : t("adminUsers.updateFailed"),
+ );
+ return false;
+ }
+ };
+
+ const onEditSubmit = async (values: EditValues) => {
+ if (!editTarget) return;
+ setEditSubmitting(true);
+ try {
+ const ok = await togglePatch(editTarget, {
+ role: values.role,
+ permissions: values.role === "admin" ? [] : values.permissions ?? [],
+ });
+ if (ok) {
+ setEditTarget(null);
+ editForm.resetFields();
+ }
+ } finally {
+ setEditSubmitting(false);
+ }
+ };
+
+ const onDelete = async (row: UserRow) => {
+ try {
+ await request(`/users/${row.id}`, { method: "DELETE" });
+ message.success(t("adminUsers.deleteSuccess"));
+ void refreshAll();
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : t("common.deleteFailed"),
+ );
+ }
+ };
+
+ const onResetSubmit = async (values: ResetValues) => {
+ if (!resetTarget) return;
+ setResetSubmitting(true);
+ try {
+ await request(`/users/${resetTarget.id}/reset-password`, {
+ method: "POST",
+ body: JSON.stringify({ new_password: values.password }),
+ });
+ message.success(t("adminUsers.resetSuccess"));
+ setResetTarget(null);
+ resetForm.resetFields();
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : t("adminUsers.resetFailed"),
+ );
+ } finally {
+ setResetSubmitting(false);
+ }
+ };
+
+ const onUnlockLogin = async (row: UserRow) => {
+ try {
+ await request(`/users/${row.id}/unlock-login`, { method: "POST" });
+ message.success(t("adminUsers.unlockLoginSuccess"));
+ void refreshUsers();
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : t("adminUsers.unlockLoginFailed"),
+ );
+ }
+ };
+
+ return (
+ <>
+
+
+
+
}
+ value={searchQuery}
+ onChange={(event) => setSearchQuery(event.target.value)}
+ placeholder={t("adminUsers.searchPlaceholder")}
+ className={styles.userSearch}
+ />
+
+ setViewMode(v as "table" | "card")}
+ options={[
+ {
+ value: "card",
+ label: (
+
+
+ {t("adminUsers.viewCard", "卡片")}
+
+ ),
+ },
+ {
+ value: "table",
+ label: (
+
+
+ {t("adminUsers.viewTable", "表格")}
+
+ ),
+ },
+ ]}
+ />
+ }
+ onClick={() => void refreshAll()}
+ >
+ {t("common.refresh")}
+
+ }
+ onClick={openCreate}
+ >
+ {t("adminUsers.newUser")}
+
+
+
+
+
+ {showCardView ? (
+ {
+ setResetTarget(row);
+ resetForm.resetFields();
+ }}
+ onDelete={onDelete}
+ onUnlockLogin={onUnlockLogin}
+ nowSec={nowSec}
+ />
+ ) : (
+
+ rowKey="id"
+ size="middle"
+ className={styles.userTable}
+ loading={loading}
+ dataSource={filteredRows}
+ pagination={false}
+ scroll={{ x: 960 }}
+ rowClassName={(row) =>
+ [
+ row.disabled ? styles.userTableRowDisabled : "",
+ row.login_locked ? styles.userTableRowLocked : "",
+ ]
+ .filter(Boolean)
+ .join(" ")
+ }
+ columns={[
+ {
+ title: t("adminUsers.colUsername"),
+ width: 240,
+ render: (_, row) => {
+ const displayName = row.display_name?.trim() || row.username;
+ return (
+
+
+ {userInitials(displayName, row.username)}
+
+
+
+ {displayName}
+ {row.id === currentUserId && (
+
+ {t("adminUsers.you")}
+
+ )}
+
+
+ @{row.username}
+
+
+
+ );
+ },
+ },
+ {
+ title: t("adminUsers.colAuth"),
+ width: 120,
+ render: (_, row) => {
+ const parts = [
+ row.sso_linked ? t("adminUsers.ssoBadge") : null,
+ row.has_password ? t("adminUsers.passwordBadge") : null,
+ ].filter(Boolean);
+ return (
+
+ {parts.length ? parts.join(" · ") : "—"}
+
+ );
+ },
+ },
+ {
+ title: t("adminUsers.colAgents"),
+ width: 80,
+ render: (_, row) => {
+ const count = agentsByUserId.get(row.id)?.length ?? 0;
+ return (
+
+ );
+ },
+ },
+ {
+ title: t("adminUsers.colRole"),
+ width: 88,
+ render: (_, row) => (
+
+ {row.role === "admin"
+ ? t("adminUsers.roleAdmin")
+ : t("adminUsers.roleUser")}
+
+ ),
+ },
+ {
+ title: t("adminUsers.colPermissions"),
+ width: 120,
+ render: (_, row) => (
+
+ ),
+ },
+ {
+ title: t("common.enabled"),
+ width: 72,
+ render: (_, row) => (
+
+ togglePatch(row, { disabled: !checked })
+ }
+ />
+ ),
+ },
+ {
+ title: t("adminUsers.colCreatedAt"),
+ dataIndex: "created_at",
+ width: 156,
+ render: (ts: number | undefined) => (
+
+ {formatUserTs(ts, timeZone)}
+
+ ),
+ },
+ {
+ title: t("adminUsers.colLoginLock"),
+ width: 180,
+ render: (_, row) => (
+ void onUnlockLogin(row)}
+ />
+ ),
+ },
+ {
+ title: t("adminUsers.colActions"),
+ width: 120,
+ render: (_, row) => (
+
+
+
+
+
+
+
+ onDelete(row)}
+ disabled={row.id === currentUserId}
+ >
+
+
+
+
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+ setAgentDrawerUser(null)}
+ width={400}
+ destroyOnHidden
+ >
+
+ {drawerAgents.length === 0 ? (
+
+ ) : (
+
+ {drawerAgents.map((agent) => (
+
+ setEditAgent(
+ drawerAgents.find((a) => a.agent_id === id) ?? null,
+ )
+ }
+ onDeleted={handleDrawerDeleted}
+ onStateChange={handleDrawerStateChange}
+ onPollSettled={() => void refreshAgents()}
+ />
+ ))}
+
+ )}
+
+
+
+ setEditAgent(null)}
+ onSaved={handleEditSaved}
+ />
+
+ {
+ setCreateOpen(false);
+ form.resetFields();
+ }}
+ width={Math.min(
+ 520,
+ typeof window !== "undefined" ? window.innerWidth - 24 : 520,
+ )}
+ destroyOnHidden
+ className={styles.createUserDrawer}
+ styles={{ body: { paddingTop: 12, paddingBottom: 24 } }}
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+ {
+ setEditTarget(null);
+ editForm.resetFields();
+ }}
+ width={Math.min(
+ 520,
+ typeof window !== "undefined" ? window.innerWidth - 24 : 520,
+ )}
+ destroyOnHidden
+ className={styles.createUserDrawer}
+ styles={{ body: { paddingTop: 12, paddingBottom: 24 } }}
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+ {
+ setResetTarget(null);
+ resetForm.resetFields();
+ }}
+ onOk={() => resetForm.submit()}
+ okText={t("common.reset")}
+ cancelText={t("common.cancel")}
+ confirmLoading={resetSubmitting}
+ >
+
+ {t("adminUsers.resetHint")}
+
+
+
+ }
+ />
+
+ ({
+ validator(_, value) {
+ if (!value || getFieldValue("password") === value) {
+ return Promise.resolve();
+ }
+ return Promise.reject(
+ new Error(t("wizard.admin.passwordMismatch")),
+ );
+ },
+ }),
+ ]}
+ >
+
+ }
+ />
+
+
+
+ >
+ );
+}
diff --git a/dashboard/src/pages/Admin/Users/index.module.less b/dashboard/src/pages/Admin/Users/index.module.less
index ef69192a..04c24bdd 100644
--- a/dashboard/src/pages/Admin/Users/index.module.less
+++ b/dashboard/src/pages/Admin/Users/index.module.less
@@ -23,12 +23,24 @@
padding: 60px;
}
-/* ── Create-user modal ──────────────────────────────────────────── */
+/* ── Create-user drawer ─────────────────────────────────────────── */
-.createUserModal {
- :global(.ant-modal-body) {
- padding-top: 12px;
+.createUserDrawer {
+ :global(.ant-drawer-header) {
+ border-bottom: 1px solid var(--fn-border-primary);
}
+
+ :global(.ant-drawer-footer) {
+ border-top: 1px solid var(--fn-border-primary);
+ padding: 12px 16px;
+ }
+}
+
+.createUserFooter {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 8px;
}
.createUserForm {
@@ -42,8 +54,195 @@
}
}
+.createSection {
+ margin-bottom: 8px;
+
+ & + & {
+ margin-top: 8px;
+ padding-top: 16px;
+ border-top: 1px solid var(--fn-border-primary);
+ }
+}
+
+.createSectionTitle {
+ margin-bottom: 12px;
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--fn-text-tertiary);
+ text-transform: uppercase;
+}
+
.createUserRoleItem {
- margin-bottom: 4px !important;
+ margin-bottom: 12px !important;
+}
+
+.createUserPermItem {
+ margin-bottom: 0 !important;
+}
+
+.permAdminHint {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin-top: 4px;
+ padding: 10px 12px;
+ border-radius: var(--fn-radius-md, 8px);
+ background: var(--fn-color-brand-bg);
+ border: 1px solid
+ var(
+ --fn-color-brand-border,
+ color-mix(in srgb, var(--fn-color-brand) 22%, transparent)
+ );
+ color: var(--fn-text-secondary);
+ font-size: 13px;
+ line-height: 1.45;
+
+ svg {
+ flex-shrink: 0;
+ margin-top: 1px;
+ color: var(--fn-color-brand);
+ }
+}
+
+.permEmpty {
+ padding: 12px 0;
+}
+
+.permPicker {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.permPickerDisabled {
+ opacity: 0.55;
+ pointer-events: none;
+}
+
+.permGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.permGroupHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 28px;
+}
+
+.permGroupTitle {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--fn-text-primary);
+}
+
+.permGroupCount {
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ color: var(--fn-text-tertiary);
+}
+
+.permPage {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 10px 10px;
+ border-radius: var(--fn-radius-md, 8px);
+ background: var(--fn-bg-secondary, var(--fn-bg-elevated));
+}
+
+.permPageHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.permPageTitle {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--fn-text-secondary);
+}
+
+.permGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+
+ @media (max-width: 480px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.permChip {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-height: 36px;
+ padding: 6px 10px;
+ text-align: left;
+ border: 1px solid var(--fn-border-primary);
+ border-radius: var(--fn-radius-md, 8px);
+ background: var(--fn-bg-elevated, var(--fn-bg-primary));
+ color: var(--fn-text-primary);
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ background 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &:hover:not(:disabled) {
+ border-color: var(--fn-border-strong, var(--fn-border-primary));
+ background: var(--fn-bg-secondary, var(--fn-bg-elevated));
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+}
+
+.permChipSelected {
+ border-color: var(
+ --fn-color-brand-border,
+ color-mix(in srgb, var(--fn-color-brand) 45%, transparent)
+ );
+ background: var(
+ --fn-color-brand-bg,
+ color-mix(in srgb, var(--fn-color-brand) 8%, transparent)
+ );
+ box-shadow: inset 0 0 0 1px
+ color-mix(in srgb, var(--fn-color-brand) 18%, transparent);
+ color: var(--fn-color-brand);
+}
+
+.permChipCheck {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ border-radius: 4px;
+ border: 1px solid var(--fn-border-primary);
+ background: var(--fn-bg-primary);
+ color: var(--fn-color-on-brand, #fff);
+}
+
+.permChipSelected .permChipCheck {
+ border-color: var(--fn-color-brand);
+ background: var(--fn-color-brand);
+}
+
+.permChipLabel {
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+ line-height: 1.3;
}
.rolePicker {
@@ -58,6 +257,7 @@
}
.roleOption {
+ --role-accent: var(--fn-text-secondary);
display: flex;
align-items: flex-start;
gap: 8px;
@@ -80,6 +280,11 @@
background: var(--fn-bg-secondary, var(--fn-bg-elevated));
}
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+
&:focus-visible {
outline: 2px solid var(--fn-color-brand);
outline-offset: 2px;
@@ -87,30 +292,43 @@
}
.roleOptionSelected {
- box-shadow: 0 0 0 1px var(--role-accent);
- border-color: var(--role-accent);
- background: color-mix(
- in srgb,
- var(--role-accent) 10%,
- var(--fn-bg-elevated, var(--fn-bg-primary))
+ border-color: var(
+ --fn-color-brand-border,
+ color-mix(in srgb, var(--fn-color-brand) 45%, transparent)
+ );
+ background: var(
+ --fn-color-brand-bg,
+ color-mix(in srgb, var(--fn-color-brand) 8%, transparent)
);
+ box-shadow: inset 0 0 0 1px
+ color-mix(in srgb, var(--fn-color-brand) 18%, transparent);
&:hover {
- border-color: var(--role-accent);
- background: color-mix(
- in srgb,
- var(--role-accent) 14%,
- var(--fn-bg-elevated, var(--fn-bg-primary))
+ border-color: var(--fn-color-brand);
+ background: var(
+ --fn-color-brand-bg,
+ color-mix(in srgb, var(--fn-color-brand) 8%, transparent)
);
}
+
+ .roleOptionIcon {
+ color: var(--fn-color-brand);
+ background: color-mix(in srgb, var(--fn-color-brand) 14%, transparent);
+ }
+
+ .roleOptionLabel {
+ color: var(--fn-color-brand);
+ }
}
-.roleOptionAdmin {
- --role-accent: #d4880e;
+.roleToneAdmin {
+ color: var(--fn-color-brand);
+ background: var(--fn-color-brand-bg);
}
-.roleOptionUser {
- --role-accent: #4f6ef7;
+.roleToneUser {
+ color: var(--fn-text-secondary);
+ background: var(--fn-bg-container, rgba(0, 0, 0, 0.04));
}
.roleOptionIcon {
@@ -237,12 +455,28 @@
flex-shrink: 0;
}
+.userCardAccentAdmin {
+ background: var(--fn-color-brand);
+}
+
+.userCardAccentUser {
+ background: var(--fn-border-strong, var(--fn-border-primary));
+}
+
+.userCardAccentLocked {
+ background: #ff4d4f;
+}
+
+.userCardAccentDisabled {
+ background: var(--fn-text-tertiary);
+}
+
.userCardInner {
display: flex;
flex-direction: column;
flex: 1;
padding: 16px 16px 12px;
- gap: 14px;
+ gap: 12px;
}
/* ── Header ─────────────────────────────────────────────────────── */
@@ -298,6 +532,9 @@
line-height: 16px !important;
padding: 0 5px !important;
border-radius: 4px !important;
+ border: none !important;
+ color: var(--fn-color-brand) !important;
+ background: var(--fn-color-brand-bg) !important;
}
.userCardHandle {
@@ -314,14 +551,10 @@
display: inline-flex;
align-items: center;
gap: 4px;
- margin-top: 4px;
- font-size: 11px;
+ font-size: 12px;
color: var(--fn-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 100%;
}
.userCardMeta {
@@ -329,7 +562,47 @@
align-items: center;
flex-wrap: wrap;
gap: 6px;
- margin-top: 8px;
+}
+
+.userCardInfo {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.userCardAuth {
+ font-size: 11px;
+ color: var(--fn-text-tertiary);
+ padding: 1px 7px;
+ border-radius: 999px;
+ background: var(--fn-bg-container, rgba(0, 0, 0, 0.04));
+}
+
+.permBadge,
+.permBadgeMuted {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ padding: 0 8px;
+ border-radius: 999px;
+ font-size: 12px;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.permBadge {
+ color: var(--fn-text-secondary);
+ background: var(--fn-bg-container, rgba(0, 0, 0, 0.04));
+}
+
+.permBadgeAll {
+ color: var(--fn-color-brand);
+ background: var(--fn-color-brand-bg);
+}
+
+.permBadgeMuted {
+ color: var(--fn-text-tertiary);
}
.userCardPill {
@@ -519,3 +792,110 @@
border-radius: var(--fn-radius-sm);
background: var(--fn-bg-container, rgba(0, 0, 0, 0.03));
}
+
+.userTable {
+ :global(.ant-table) {
+ background: transparent;
+ }
+
+ :global(.ant-table-container) {
+ border: 1px solid var(--fn-border-primary);
+ border-radius: var(--fn-radius-lg, 12px);
+ overflow: hidden;
+ }
+
+ :global(.ant-table-thead > tr > th) {
+ background: var(--fn-bg-secondary, var(--fn-bg-elevated));
+ color: var(--fn-text-tertiary);
+ font-size: 12px;
+ font-weight: 600;
+ padding: 10px 12px;
+ }
+
+ :global(.ant-table-tbody > tr > td) {
+ padding: 10px 12px;
+ vertical-align: middle;
+ }
+}
+
+.userTableRowDisabled {
+ opacity: 0.72;
+}
+
+.userTableRowLocked :global(td) {
+ background: rgba(255, 77, 79, 0.03);
+}
+
+.userCell {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.userCellAvatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 9px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.userCellText {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: 1px;
+}
+
+.userCellName {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--fn-text-primary);
+ line-height: 1.3;
+}
+
+.userCellYou {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--fn-color-brand);
+ background: var(--fn-color-brand-bg);
+ border-radius: 4px;
+ padding: 0 5px;
+ line-height: 16px;
+}
+
+.userCellHandle {
+ font-size: 12px;
+ color: var(--fn-text-tertiary);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.userCellMuted {
+ font-size: 12px;
+ color: var(--fn-text-tertiary);
+}
+
+.userCellLink {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--fn-text-secondary);
+ cursor: pointer;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+
+ &:hover {
+ color: var(--fn-text-primary);
+ }
+}
diff --git a/dashboard/src/pages/Admin/Users/index.tsx b/dashboard/src/pages/Admin/Users/index.tsx
index bccf1353..42c66e95 100644
--- a/dashboard/src/pages/Admin/Users/index.tsx
+++ b/dashboard/src/pages/Admin/Users/index.tsx
@@ -1,1187 +1,58 @@
-/**
- * Admin → Users page (plan §14.7).
- *
- * List all users with role/disabled toggles, password reset, delete.
- * Card and table views; default is card on mobile, table on desktop. The view switcher + refresh +
- * new-user buttons live in a content-area toolbar (mirrors the Experts
- * page layout). Each row/card shows agent count; click opens a drawer
- * with that user's agents.
- *
- * Endpoints (all require admin role; backend returns 403 otherwise):
- * GET /api/users
- * POST /api/users
- * PATCH /api/users/{id}
- * POST /api/users/{id}/reset-password
- * DELETE /api/users/{id}
- */
-
-import { useEffect, useMemo, useState, useCallback } from "react";
-import {
- Table,
- Button,
- Modal,
- Form,
- Input,
- Select,
- Space,
- Popconfirm,
- Switch,
- Typography,
- Tooltip,
- Drawer,
- Empty,
- Spin,
- Tag,
- Segmented,
-} from "antd";
-import { message } from "@/utils/antdMessage";
-
-import {
- Bot,
- ChevronRight,
- Clock,
- IdCard,
- KeyRound,
- LayoutGrid,
- List,
- Lock,
- LockOpen,
- Plus,
- RefreshCw,
- Search,
- ShieldCheck,
- Trash2,
- User,
- UserRound,
-} from "lucide-react";
import { useTranslation } from "react-i18next";
+import { KeyRound, Users } from "lucide-react";
+import type { ReactNode } from "react";
import PageShell from "../../../layouts/PageShell";
-import { request } from "../../../api/request";
-import { authApi } from "../../../api/modules/auth";
-import { useCardTableView } from "../../../hooks/useCardTableView";
-import { useServerTimezone } from "../../../hooks/useServerTimezone";
-import { formatServerDateTime } from "../../../utils/formatMessageTime";
-import type { OctopAgent } from "../../../context/AgentContext";
-import { AgentCard } from "../../Experts/components/AgentCard";
-import EditAgentDrawer from "../../Experts/components/EditAgentDrawer";
-import expertStyles from "../../Experts/index.module.less";
-import styles from "./index.module.less";
-
-const { Text } = Typography;
-
-interface UserRow {
- id: number;
- username: string;
- role: "admin" | "user";
- display_name: string | null;
- disabled: boolean;
- login_failed_count?: number;
- login_locked?: boolean;
- login_locked_until?: number;
- login_retry_after_seconds?: number;
- created_at?: number;
-}
-
-interface CreateValues {
- username: string;
- display_name?: string;
- password: string;
- confirm: string;
- role: "admin" | "user";
-}
-
-interface ResetValues {
- password: string;
- confirm: string;
-}
-
-interface RoleMeta {
- color: string;
- bg: string;
-}
-
-const ROLE_META: Record<"admin" | "user", RoleMeta> = {
- admin: { color: "#d4880e", bg: "rgba(212,136,14,0.10)" },
- user: { color: "#1677ff", bg: "rgba(22,119,255,0.10)" },
-};
-
-function useNowSeconds(active: boolean): number {
- const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
- useEffect(() => {
- if (!active) return;
- const id = window.setInterval(
- () => setNow(Math.floor(Date.now() / 1000)),
- 1000,
- );
- return () => window.clearInterval(id);
- }, [active]);
- return now;
-}
-
-function lockRemainingSeconds(row: UserRow, nowSec: number): number {
- if (!row.login_locked || !row.login_locked_until) return 0;
- return Math.max(0, row.login_locked_until - nowSec);
-}
-
-function formatUserTs(ts: number | undefined, timeZone: string): string {
- if (!ts) return "—";
- return formatServerDateTime(ts, timeZone);
-}
-
-interface UserCardGridProps {
- rows: UserRow[];
- loading: boolean;
- agentsByUserId: Map;
- agentsLoading: boolean;
- currentUserId: number | null;
- roleOptions: { value: "admin" | "user"; label: string }[];
- onTogglePatch: (
- row: UserRow,
- patch: Partial>,
- ) => Promise;
- onShowAgents: (row: UserRow) => void;
- onResetPassword: (row: UserRow) => void;
- onDelete: (row: UserRow) => Promise;
- onUnlockLogin: (row: UserRow) => Promise;
- nowSec: number;
- isSelfAdmin: (row: UserRow) => boolean;
-}
-
-function userInitials(displayName: string, username: string): string {
- const source = displayName.trim() || username;
- const parts = source.split(/[\s._-]+/).filter(Boolean);
- if (parts.length >= 2) {
- return (parts[0][0] + parts[1][0]).toUpperCase();
- }
- return source.slice(0, 2).toUpperCase();
-}
-
-const FIELD_ICON_PROPS = {
- size: 16 as const,
- style: { color: "var(--fn-text-tertiary)" },
-};
-
-interface RolePickerProps {
- value?: "admin" | "user";
- onChange?: (value: "admin" | "user") => void;
- options: {
- value: "admin" | "user";
- label: string;
- hint: string;
- }[];
-}
-
-function RolePicker({ value, onChange, options }: RolePickerProps) {
- return (
-
- {options.map((opt) => {
- const selected = value === opt.value;
- const Icon = opt.value === "admin" ? ShieldCheck : UserRound;
- return (
-
- );
- })}
-
- );
-}
-
-function RoleLegend() {
- const { t } = useTranslation();
- return (
-
-
- {t("adminUsers.roleLegendTitle")}
-
-
{t("adminUsers.roleLegend")}
-
- );
-}
-
-function UserCardGrid({
- rows,
- loading,
- agentsByUserId,
- agentsLoading,
- currentUserId,
- roleOptions,
- onTogglePatch,
- onShowAgents,
- onResetPassword,
- onDelete,
- onUnlockLogin,
- nowSec,
- isSelfAdmin,
-}: UserCardGridProps) {
- const { t } = useTranslation();
- const timeZone = useServerTimezone();
- if (loading && rows.length === 0) {
- return (
-
-
-
- );
- }
- if (rows.length === 0) {
- return ;
- }
- return (
-
- {rows.map((row) => {
- const agentCount = agentsByUserId.get(row.id)?.length ?? 0;
- const isSelf = row.id === currentUserId;
- const displayName = row.display_name?.trim() || row.username;
- const remaining = lockRemainingSeconds(row, nowSec);
- const isLocked = remaining > 0;
- const roleMeta = ROLE_META[row.role] ?? ROLE_META.user;
- const failedCount = row.login_failed_count ?? 0;
- const accentColor = isLocked
- ? "#ff4d4f"
- : row.disabled
- ? "#8c8c8c"
- : roleMeta.color;
- const statusColor = row.disabled ? "#8c8c8c" : "#52c41a";
- const statusBg = row.disabled
- ? "rgba(140,140,140,0.10)"
- : "rgba(82,196,26,0.10)";
- return (
-
-
-
-
-
-
- {userInitials(displayName, row.username)}
-
-
-
-
- {displayName}
- {isSelf && (
-
- {t("adminUsers.you")}
-
- )}
-
-
@{row.username}
- {row.created_at != null && (
-
-
-
- {formatUserTs(row.created_at, timeZone)}
-
-
- )}
-
-
-
-
-
- {row.disabled
- ? t("adminUsers.statusDisabled")
- : t("adminUsers.statusEnabled")}
-
-
-
-
-
- void onTogglePatch(row, { disabled: !checked })
- }
- className={styles.userCardSwitch}
- aria-label={t("common.enabled")}
- />
-
-
-
-
-
-
- {isLocked && (
-
-
-
- {t("adminUsers.loginLockActive", {
- minutes: Math.max(1, Math.ceil(remaining / 60)),
- })}
-
-
-
- )}
-
- {!isLocked && failedCount > 0 && (
-
- {t("adminUsers.loginFailedCount", { count: failedCount })}
-
- )}
-
-
-
-
-
-
-
void onDelete(row)}
- disabled={isSelf}
- >
-
-
-
-
-
-
-
-
#{row.id}
-
-
-
- );
- })}
-
- );
-}
-
-/**
- * Compact login-lock status indicator. Used by both the card view
- * (inline in a `userCard2Row`) and the table view (table cell).
- */
-function UserLoginLock({
- row,
- nowSec,
- onUnlock,
-}: {
- row: UserRow;
- nowSec: number;
- onUnlock: () => void;
-}) {
- const { t } = useTranslation();
- const failedCount = row.login_failed_count ?? 0;
- if (!row.login_locked) {
- if (failedCount > 0) {
- return (
-
- {t("adminUsers.loginFailedCount", { count: failedCount })}
-
- );
- }
- return (
-
- {t("adminUsers.loginLockNone")}
-
- );
- }
- const remaining = lockRemainingSeconds(row, nowSec);
- const minutes = Math.max(1, Math.ceil(remaining / 60));
- return (
-
-
- {t("adminUsers.loginLockActive", { minutes })}
-
-
-
- );
+import SettingsTabBar from "../../Settings/shared/SettingsTabBar";
+import UsersListPanel from "./UsersListPanel";
+import SsoPanel from "./SsoPanel";
+import ForbiddenPage from "../../../components/ForbiddenPage";
+import { useGatedSearchTabs } from "../../../hooks/useGatedSearchTabs";
+import { USERS_TAB_PERMISSIONS } from "../../../utils/permissions";
+
+type TabKey = "local" | "sso";
+
+const TABS: { key: TabKey; labelKey: string; icon: ReactNode }[] = [
+ {
+ key: "local",
+ labelKey: "adminUsers.tabLocal",
+ icon: ,
+ },
+ {
+ key: "sso",
+ labelKey: "adminUsers.tabSso",
+ icon: ,
+ },
+];
+
+function parseTab(raw: string | null): TabKey {
+ if (raw === "sso") return "sso";
+ return "local";
}
export default function AdminUsersPage() {
const { t } = useTranslation();
- const timeZone = useServerTimezone();
- const [agents, setAgents] = useState([]);
- const [agentsLoading, setAgentsLoading] = useState(true);
- const [rows, setRows] = useState([]);
- const [loading, setLoading] = useState(true);
- const [createOpen, setCreateOpen] = useState(false);
- const [submitting, setSubmitting] = useState(false);
- const [form] = Form.useForm();
- const [resetTarget, setResetTarget] = useState(null);
- const [resetSubmitting, setResetSubmitting] = useState(false);
- const [resetForm] = Form.useForm();
- const [currentUserId, setCurrentUserId] = useState(null);
- const [agentDrawerUser, setAgentDrawerUser] = useState(null);
- const [editAgent, setEditAgent] = useState(null);
- const [searchQuery, setSearchQuery] = useState("");
- const { viewMode, setViewMode, showCardView } = useCardTableView("table");
-
- const roleOptions = [
- { value: "admin" as const, label: t("adminUsers.roleAdmin") },
- { value: "user" as const, label: t("adminUsers.roleUser") },
- ];
-
- const createRoleOptions = useMemo(
- () => [
- {
- value: "user" as const,
- label: t("adminUsers.roleUser"),
- hint: t("adminUsers.roleUserHint"),
- },
- {
- value: "admin" as const,
- label: t("adminUsers.roleAdmin"),
- hint: t("adminUsers.roleAdminHint"),
- },
- ],
- [t],
- );
-
- const isSelfAdmin = useCallback(
- (row: UserRow) => row.id === currentUserId && row.role === "admin",
- [currentUserId],
- );
-
- const hasLockedUser = useMemo(
- () => rows.some((row) => row.login_locked),
- [rows],
- );
- const nowSec = useNowSeconds(hasLockedUser);
-
- const agentsByUserId = useMemo(() => {
- const map = new Map();
- for (const agent of agents) {
- if (agent.user_id == null) continue;
- const list = map.get(agent.user_id) ?? [];
- list.push(agent);
- map.set(agent.user_id, list);
- }
- return map;
- }, [agents]);
-
- const drawerAgents = agentDrawerUser
- ? agentsByUserId.get(agentDrawerUser.id) ?? []
- : [];
-
- const filteredRows = useMemo(() => {
- const query = searchQuery.trim().toLowerCase();
- if (!query) return rows;
- return rows.filter((row) => {
- const username = row.username.toLowerCase();
- const displayName = (row.display_name ?? "").trim().toLowerCase();
- return username.includes(query) || displayName.includes(query);
- });
- }, [rows, searchQuery]);
-
- const refreshUsers = useCallback(async () => {
- setLoading(true);
- try {
- const data = await request("/users");
- setRows(data);
- } catch (err) {
- message.error(
- err instanceof Error ? err.message : t("adminUsers.loadFailed"),
- );
- } finally {
- setLoading(false);
- }
- }, [t]);
-
- useEffect(() => {
- if (!hasLockedUser) return;
- const anyExpired = rows.some(
- (row) => row.login_locked && lockRemainingSeconds(row, nowSec) === 0,
- );
- if (anyExpired) void refreshUsers();
- }, [hasLockedUser, nowSec, rows, refreshUsers]);
-
- const refreshAgents = useCallback(async () => {
- setAgentsLoading(true);
- try {
- const data = await request("/agents?scope=all");
- setAgents(data);
- } catch (err) {
- message.error(
- err instanceof Error ? err.message : t("adminUsers.loadFailed"),
- );
- setAgents([]);
- } finally {
- setAgentsLoading(false);
- }
- }, [t]);
+ const { allowedTabs, activeTab, forbidden, selectTab } = useGatedSearchTabs({
+ tabs: TABS,
+ tabPermissions: USERS_TAB_PERMISSIONS,
+ parseTab,
+ querylessKey: "local",
+ });
- const patchAgent = useCallback(
- (agentId: string, patch: Partial) => {
- setAgents((prev) =>
- prev.map((a) => (a.agent_id === agentId ? { ...a, ...patch } : a)),
- );
- },
- [],
- );
-
- const handleDrawerStateChange = useCallback(
- (agentId: string, newState: string) => {
- patchAgent(agentId, { state: newState });
- },
- [patchAgent],
- );
-
- const handleDrawerDeleted = useCallback(
- (agentId: string) => {
- setAgents((prev) => prev.filter((a) => a.agent_id !== agentId));
- void refreshAgents();
- },
- [refreshAgents],
- );
-
- const handleEditSaved = useCallback(
- (
- updated: Pick<
- OctopAgent,
- "agent_id" | "name" | "description" | "default_model"
- >,
- ) => {
- setEditAgent(null);
- patchAgent(updated.agent_id, {
- name: updated.name,
- description: updated.description,
- default_model: updated.default_model,
- });
- void refreshAgents();
- },
- [patchAgent, refreshAgents],
- );
-
- const refreshAll = useCallback(async () => {
- await Promise.all([refreshUsers(), refreshAgents()]);
- }, [refreshUsers, refreshAgents]);
-
- useEffect(() => {
- void refreshAll();
- authApi
- .me()
- .then((u) => setCurrentUserId(u.id))
- .catch(() => setCurrentUserId(null));
- }, [refreshAll]);
-
- const onCreate = async (values: CreateValues) => {
- setSubmitting(true);
- try {
- await request("/users", {
- method: "POST",
- body: JSON.stringify({
- username: values.username,
- display_name: values.display_name?.trim() || null,
- password: values.password,
- role: values.role,
- }),
- });
- message.success(
- t("adminUsers.createSuccess", { username: values.username }),
- );
- form.resetFields();
- setCreateOpen(false);
- void refreshUsers();
- } catch (err) {
- message.error(
- err instanceof Error ? err.message : t("adminUsers.createFailed"),
- );
- } finally {
- setSubmitting(false);
- }
- };
-
- const togglePatch = async (
- row: UserRow,
- patch: Partial>,
- ) => {
- if (
- patch.role === "user" &&
- row.id === currentUserId &&
- row.role === "admin"
- ) {
- message.warning(t("adminUsers.demoteSelf"));
- return;
- }
- try {
- await request(`/users/${row.id}`, {
- method: "PATCH",
- body: JSON.stringify(patch),
- });
- void refreshUsers();
- } catch (err) {
- message.error(
- err instanceof Error ? err.message : t("adminUsers.updateFailed"),
- );
- }
- };
-
- const onDelete = async (row: UserRow) => {
- try {
- await request(`/users/${row.id}`, { method: "DELETE" });
- message.success(t("adminUsers.deleteSuccess"));
- void refreshAll();
- } catch (err) {
- message.error(
- err instanceof Error ? err.message : t("common.deleteFailed"),
- );
- }
- };
-
- const onResetSubmit = async (values: ResetValues) => {
- if (!resetTarget) return;
- setResetSubmitting(true);
- try {
- await request(`/users/${resetTarget.id}/reset-password`, {
- method: "POST",
- body: JSON.stringify({ new_password: values.password }),
- });
- message.success(t("adminUsers.resetSuccess"));
- setResetTarget(null);
- resetForm.resetFields();
- } catch (err) {
- message.error(
- err instanceof Error ? err.message : t("adminUsers.resetFailed"),
- );
- } finally {
- setResetSubmitting(false);
- }
- };
-
- const onUnlockLogin = async (row: UserRow) => {
- try {
- await request(`/users/${row.id}/unlock-login`, { method: "POST" });
- message.success(t("adminUsers.unlockLoginSuccess"));
- void refreshUsers();
- } catch (err) {
- message.error(
- err instanceof Error ? err.message : t("adminUsers.unlockLoginFailed"),
- );
- }
- };
+ if (forbidden) return ;
return (
-
-
-
-
}
- value={searchQuery}
- onChange={(event) => setSearchQuery(event.target.value)}
- placeholder={t("adminUsers.searchPlaceholder")}
- className={styles.userSearch}
- />
-
- setViewMode(v as "table" | "card")}
- options={[
- {
- value: "card",
- label: (
-
-
- {t("adminUsers.viewCard", "卡片")}
-
- ),
- },
- {
- value: "table",
- label: (
-
-
- {t("adminUsers.viewTable", "表格")}
-
- ),
- },
- ]}
- />
- }
- onClick={() => void refreshAll()}
- >
- {t("common.refresh")}
-
- }
- onClick={() => setCreateOpen(true)}
- >
- {t("adminUsers.newUser")}
-
-
-
-
-
-
- {showCardView ? (
- {
- setResetTarget(row);
- resetForm.resetFields();
- }}
- onDelete={onDelete}
- onUnlockLogin={onUnlockLogin}
- nowSec={nowSec}
- isSelfAdmin={isSelfAdmin}
- />
- ) : (
-
- rowKey="id"
- loading={loading}
- dataSource={filteredRows}
- pagination={false}
- scroll={{ x: "max-content" }}
- columns={[
- { title: t("adminUsers.colId"), dataIndex: "id", width: 60 },
- { title: t("adminUsers.colUsername"), dataIndex: "username" },
- {
- title: t("adminUsers.colDisplayName"),
- dataIndex: "display_name",
- },
- {
- title: t("adminUsers.colCreatedAt"),
- dataIndex: "created_at",
- width: 168,
- render: (ts: number | undefined) => (
-
- {formatUserTs(ts, timeZone)}
-
- ),
- },
- {
- title: t("adminUsers.colAgents"),
- width: 96,
- render: (_, row) => {
- const count = agentsByUserId.get(row.id)?.length ?? 0;
- return (
- }
- onClick={() => setAgentDrawerUser(row)}
- >
- {agentsLoading ? "…" : count}
-
- );
- },
- },
- {
- title: t("adminUsers.colRole"),
- render: (_, row) => (
-
-
- ),
- },
- {
- title: t("common.enabled"),
- render: (_, row) => (
-
- togglePatch(row, { disabled: !checked })
- }
- />
- ),
- },
- {
- title: t("adminUsers.colLoginLock"),
- width: 200,
- render: (_, row) => (
- void onUnlockLogin(row)}
- />
- ),
- },
- {
- title: t("adminUsers.colActions"),
- render: (_, row) => (
-
-
- onDelete(row)}
- disabled={row.id === currentUserId}
- >
-
-
-
-
-
- ),
- },
- ]}
+ tabBar={
+
- )}
-
- setAgentDrawerUser(null)}
- width={400}
- destroyOnHidden
- >
-
- {drawerAgents.length === 0 ? (
-
- ) : (
-
- {drawerAgents.map((agent) => (
-
- setEditAgent(
- drawerAgents.find((a) => a.agent_id === id) ?? null,
- )
- }
- onDeleted={handleDrawerDeleted}
- onStateChange={handleDrawerStateChange}
- onPollSettled={() => void refreshAgents()}
- />
- ))}
-
- )}
-
-
-
- setEditAgent(null)}
- onSaved={handleEditSaved}
- />
-
- {
- setCreateOpen(false);
- form.resetFields();
- }}
- onOk={() => form.submit()}
- okText={t("common.create")}
- cancelText={t("common.cancel")}
- confirmLoading={submitting}
- destroyOnHidden
- className={styles.createUserModal}
- >
-
- } autoFocus />
-
-
- } />
-
-
- }
- autoComplete="new-password"
- />
-
- ({
- validator(_, value) {
- if (!value || getFieldValue("password") === value) {
- return Promise.resolve();
- }
- return Promise.reject(
- new Error(t("wizard.admin.passwordMismatch")),
- );
- },
- }),
- ]}
- >
- }
- autoComplete="new-password"
- />
-
-
-
-
-
-
-
- {
- setResetTarget(null);
- resetForm.resetFields();
- }}
- onOk={() => resetForm.submit()}
- okText={t("common.reset")}
- cancelText={t("common.cancel")}
- confirmLoading={resetSubmitting}
- >
-
- {t("adminUsers.resetHint")}
-
-
-
- }
- />
-
- ({
- validator(_, value) {
- if (!value || getFieldValue("password") === value) {
- return Promise.resolve();
- }
- return Promise.reject(
- new Error(t("wizard.admin.passwordMismatch")),
- );
- },
- }),
- ]}
- >
-
- }
- />
-
-
-
-
+ }
+ >
+ {activeTab === "local" ? : }
+
);
}
diff --git a/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx b/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx
index 165e6af5..9c0971f7 100644
--- a/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx
+++ b/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx
@@ -16,7 +16,7 @@ import {
useChannels,
CHANNEL_KEYS,
DEFAULT_CHANNEL_DISPLAY_CONFIG,
- CHANNEL_BOOLEAN_CONFIG_KEYS,
+ CHANNEL_DISPLAY_CONFIG_KEYS,
CHANNEL_FIELDS,
DEFAULT_QQ_GROUP_CONTEXT_CONFIG,
normalizeChannelFieldValue,
@@ -30,8 +30,11 @@ import styles from "./index.module.less";
function configFromFormValues(
values: ChannelFormValues,
): Record {
- const { __raw_config, show_thinking, show_tool_hints } = values;
+ const { __raw_config, response_mode, show_thinking, show_tool_hints } =
+ values;
let config: Record = {
+ response_mode:
+ response_mode ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode,
show_thinking:
show_thinking ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.show_thinking,
show_tool_hints:
@@ -46,6 +49,7 @@ function configFromFormValues(
k === "name" ||
k === "enabled" ||
k === "__raw_config" ||
+ k === "response_mode" ||
k === "show_thinking" ||
k === "show_tool_hints"
) {
@@ -63,6 +67,15 @@ function configFromFormValues(
}
}
}
+ config = {
+ ...config,
+ response_mode:
+ response_mode ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode,
+ show_thinking:
+ show_thinking ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.show_thinking,
+ show_tool_hints:
+ show_tool_hints ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.show_tool_hints,
+ };
return config;
}
@@ -160,8 +173,8 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) {
for (const [k, v] of Object.entries(cfg)) {
if (v === undefined || v === null) continue;
if (
- CHANNEL_BOOLEAN_CONFIG_KEYS.includes(
- k as (typeof CHANNEL_BOOLEAN_CONFIG_KEYS)[number],
+ CHANNEL_DISPLAY_CONFIG_KEYS.includes(
+ k as (typeof CHANNEL_DISPLAY_CONFIG_KEYS)[number],
)
) {
continue;
@@ -186,6 +199,10 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) {
const next: ChannelFormValues = {
kind: row.kind as ChannelKey,
enabled: row.enabled,
+ response_mode:
+ cfg.response_mode === "stream"
+ ? "stream"
+ : DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode,
show_thinking:
typeof cfg.show_thinking === "boolean"
? cfg.show_thinking
diff --git a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx
index 8c393a34..305e0d06 100644
--- a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx
+++ b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx
@@ -74,6 +74,7 @@ export interface ChannelFormValues {
kind: ChannelKey;
name?: string;
enabled?: boolean;
+ response_mode?: "invoke" | "stream";
show_thinking?: boolean;
show_tool_hints?: boolean;
group_context?: QqGroupContextConfig;
@@ -390,6 +391,9 @@ function QqGroupContextPolicyFields({
function DisplaySettingsFields() {
const { t } = useTranslation();
+ const responseMode =
+ Form.useWatch("response_mode") ??
+ DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode;
return (
@@ -403,13 +407,31 @@ function DisplaySettingsFields() {
>
+
+
+
-
+
-
+
);
@@ -766,10 +788,16 @@ export function ChannelDrawer({
const getDisplayConfig = useCallback((): Pick<
ChannelFormValues,
- "show_thinking" | "show_tool_hints"
+ "response_mode" | "show_thinking" | "show_tool_hints"
> => {
- const values = form.getFieldsValue(["show_thinking", "show_tool_hints"]);
+ const values = form.getFieldsValue([
+ "response_mode",
+ "show_thinking",
+ "show_tool_hints",
+ ]);
return {
+ response_mode:
+ values.response_mode ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode,
show_thinking:
values.show_thinking ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.show_thinking,
show_tool_hints:
@@ -969,9 +997,17 @@ export function ChannelDrawer({
};
const handleFinish = (values: ChannelFormValues) => {
- const { kind, __raw_config, show_thinking, show_tool_hints, ...rest } =
- values;
+ const {
+ kind,
+ __raw_config,
+ response_mode,
+ show_thinking,
+ show_tool_hints,
+ ...rest
+ } = values;
let config: Record
= {
+ response_mode:
+ response_mode ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode,
show_thinking:
show_thinking ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.show_thinking,
show_tool_hints:
@@ -995,6 +1031,15 @@ export function ChannelDrawer({
}
}
}
+ config = {
+ ...config,
+ response_mode:
+ response_mode ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode,
+ show_thinking:
+ show_thinking ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.show_thinking,
+ show_tool_hints:
+ show_tool_hints ?? DEFAULT_CHANNEL_DISPLAY_CONFIG.show_tool_hints,
+ };
void (async () => {
const ok = await onSubmit(kind, kind, config, values.enabled ?? false);
if (ok) clearFormDraft(draftScope);
diff --git a/dashboard/src/pages/Agent/Channels/components/constants.test.ts b/dashboard/src/pages/Agent/Channels/components/constants.test.ts
index 8f5c93e7..7eb71ee4 100644
--- a/dashboard/src/pages/Agent/Channels/components/constants.test.ts
+++ b/dashboard/src/pages/Agent/Channels/components/constants.test.ts
@@ -1,10 +1,17 @@
import { describe, expect, it } from "vitest";
import {
+ DEFAULT_CHANNEL_DISPLAY_CONFIG,
DEFAULT_QQ_GROUP_CONTEXT_CONFIG,
normalizeQqGroupContextConfig,
} from "./constants";
+describe("channel display defaults", () => {
+ it("uses invoke delivery for external IM channels", () => {
+ expect(DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode).toBe("invoke");
+ });
+});
+
describe("normalizeQqGroupContextConfig", () => {
it("uses safe QQ group defaults for missing config", () => {
expect(normalizeQqGroupContextConfig(undefined)).toEqual(
diff --git a/dashboard/src/pages/Agent/Channels/components/constants.ts b/dashboard/src/pages/Agent/Channels/components/constants.ts
index a23ae441..e40f4a4c 100644
--- a/dashboard/src/pages/Agent/Channels/components/constants.ts
+++ b/dashboard/src/pages/Agent/Channels/components/constants.ts
@@ -362,14 +362,16 @@ export const CHANNEL_FIELDS: Partial> = {
// dashboard & agentchat: no required credentials.
};
-/** Config keys stored as booleans — excluded from credential string mapping. */
-export const CHANNEL_BOOLEAN_CONFIG_KEYS = [
+/** Display-only config keys — excluded from credential field mapping. */
+export const CHANNEL_DISPLAY_CONFIG_KEYS = [
"show_thinking",
"show_tool_hints",
+ "response_mode",
] as const;
/** Default per-channel display settings (harness-gateway ChannelConfig). */
export const DEFAULT_CHANNEL_DISPLAY_CONFIG = {
+ response_mode: "invoke" as const,
show_thinking: false,
show_tool_hints: false,
} as const;
diff --git a/dashboard/src/pages/Agent/Channels/components/index.ts b/dashboard/src/pages/Agent/Channels/components/index.ts
index e1f5c294..2bfc62bf 100644
--- a/dashboard/src/pages/Agent/Channels/components/index.ts
+++ b/dashboard/src/pages/Agent/Channels/components/index.ts
@@ -18,7 +18,7 @@ export {
CHANNEL_URLS,
CHANNEL_FIELDS,
REQUIRED_CREDENTIALS,
- CHANNEL_BOOLEAN_CONFIG_KEYS,
+ CHANNEL_DISPLAY_CONFIG_KEYS,
DEFAULT_CHANNEL_DISPLAY_CONFIG,
DEFAULT_QQ_GROUP_CONTEXT_CONFIG,
normalizeChannelFieldValue,
diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx
index 2b7bdd33..70e84e5a 100644
--- a/dashboard/src/pages/Agent/Connectors/index.tsx
+++ b/dashboard/src/pages/Agent/Connectors/index.tsx
@@ -16,7 +16,8 @@ import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import PageShell from "../../../layouts/PageShell";
-import { useUserRole } from "../../../hooks/useUserRole";
+import { useCurrentUser } from "../../../hooks/useCurrentUser";
+import { userCan } from "../../../utils/permissions";
import { apiErrorMessage } from "../../../utils/apiError";
import {
clearFormDraft,
@@ -239,8 +240,8 @@ function ConnectorConfigDrawer({
onSaved: () => void;
}) {
const { t } = useTranslation();
- const role = useUserRole();
- const isAdmin = role === "admin";
+ const user = useCurrentUser();
+ const canInstallCli = userCan(user, "connectors");
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [probing, setProbing] = useState(false);
@@ -922,7 +923,7 @@ function ConnectorConfigDrawer({
{entry && isHostCliConnector(entry.kind) && (
<>
- {isAdmin && (
+ {canInstallCli && (
)}
- {!isAdmin && !cliInfo?.installed && (
+ {!canInstallCli && !cliInfo?.installed && (
{t(
"connectors.cliInstallAdminOnly",
diff --git a/dashboard/src/pages/Agent/Personalization/index.tsx b/dashboard/src/pages/Agent/Personalization/index.tsx
index cfbd4bd2..8a123ed6 100644
--- a/dashboard/src/pages/Agent/Personalization/index.tsx
+++ b/dashboard/src/pages/Agent/Personalization/index.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from "react";
+import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Empty } from "antd";
import { Bot, Brain, Notebook, Sparkles, Waypoints } from "lucide-react";
@@ -6,6 +6,8 @@ import PageShell, { pageShellStyles } from "../../../layouts/PageShell";
import { useAgent } from "../../../context/AgentContext";
import { useIsMobile } from "../../../hooks/useIsMobile";
import { usePathTabs } from "../../../hooks/usePathTabs";
+import { useCurrentUser } from "../../../hooks/useCurrentUser";
+import { userCan } from "../../../utils/permissions";
import SkillsTabs from "../Skills/components/SkillsTabs";
import SubagentManager from "../../Experts/components/SubagentManager";
import MBTISelector from "./components/MBTISelector";
@@ -39,30 +41,39 @@ const TAB_ICONS = {
export default function PersonalizationPage() {
const { t } = useTranslation();
const isMobile = useIsMobile();
+ const user = useCurrentUser();
const { activeAgentId, agents } = useAgent();
const activeAgent = agents.find((a) => a.agent_id === activeAgentId);
+ const isAllowed = useCallback(
+ (tab: PersonalizationTab) =>
+ tab !== "channels" || userCan(user, "channels"),
+ [user],
+ );
const { activeTab, handleTabChange, isMounted } = usePathTabs({
basePath: "/personalization",
tabs: PERSONALIZATION_TABS,
storageKey: "octop:personalization:tab",
defaultTab: "skills",
+ isAllowed,
});
const pathTabs = useMemo(
() => ({
value: activeTab,
onChange: handleTabChange,
- options: PERSONALIZATION_TABS.map((value) => {
- const Icon = TAB_ICONS[value];
- return {
- value,
- label: t(`personalization.tabs.${value}`),
- icon: ,
- };
- }),
+ options: PERSONALIZATION_TABS.filter((value) => isAllowed(value)).map(
+ (value) => {
+ const Icon = TAB_ICONS[value];
+ return {
+ value,
+ label: t(`personalization.tabs.${value}`),
+ icon: ,
+ };
+ },
+ ),
}),
- [activeTab, handleTabChange, t],
+ [activeTab, handleTabChange, isAllowed, t],
);
const pageTitle = `${t("personalization.title")} / ${t(
diff --git a/dashboard/src/pages/Agent/Skills/components/AgentPickerModal.tsx b/dashboard/src/pages/Agent/Skills/components/AgentPickerModal.tsx
index 0803f982..0cb1c7e5 100644
--- a/dashboard/src/pages/Agent/Skills/components/AgentPickerModal.tsx
+++ b/dashboard/src/pages/Agent/Skills/components/AgentPickerModal.tsx
@@ -3,6 +3,7 @@ import { Modal, List, Avatar, Empty } from "antd";
import { useTranslation } from "react-i18next";
import { useAgent } from "../../../../context/AgentContext";
import type { OctopAgent } from "../../../../context/AgentContext";
+import { ownedExperts } from "../../../../utils/sharedExpert";
interface AgentPickerModalProps {
open: boolean;
@@ -17,6 +18,7 @@ export default function AgentPickerModal({
}: AgentPickerModalProps) {
const { t } = useTranslation();
const { agents } = useAgent();
+ const selectable = ownedExperts(agents);
return (
- {agents.length === 0 ? (
+ {selectable.length === 0 ? (
) : (
(
Promise;
@@ -46,6 +49,7 @@ interface InstalledSkillsTabProps {
export default function InstalledSkillsTab({
kind,
+ agentId,
skills,
loading,
fetchSkills,
@@ -59,6 +63,14 @@ export default function InstalledSkillsTab({
deleteSkill,
}: InstalledSkillsTabProps) {
const { t } = useTranslation();
+ const { agents } = useAgent();
+ const workspaceReady = useMemo(
+ () =>
+ isAgentChatReady(
+ agents.find((agent) => agent.agent_id === agentId)?.state,
+ ),
+ [agentId, agents],
+ );
const { viewMode, setViewMode, showCardView } = useCardTableView("card");
const [refreshing, setRefreshing] = useState(false);
@@ -269,6 +281,8 @@ export default function InstalledSkillsTab({
open={drawerOpen}
editingSkill={editingSkill}
form={form}
+ agentId={agentId}
+ workspaceReady={workspaceReady}
onClose={handleDrawerClose}
onSubmit={handleSubmit}
/>
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.module.less b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.module.less
index a1f8a9b9..d1ee26a3 100644
--- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.module.less
+++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.module.less
@@ -5,6 +5,82 @@
min-height: 0;
}
+.splitBody {
+ display: flex;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.singleBody {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.mainPane {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.mainPaneTreeCollapsed {
+ padding-left: 52px;
+}
+
+.fileTreeExpandBtn {
+ position: absolute;
+ top: 16px;
+ left: 12px;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: 8px;
+ background: var(--fn-bg-container, var(--fn-bg-primary));
+ color: var(--fn-text-tertiary);
+ box-shadow: var(--fn-shadow-sm, 0 1px 4px rgb(0 0 0 / 8%));
+ cursor: pointer;
+ transition:
+ background 0.15s ease,
+ color 0.15s ease,
+ border-color 0.15s ease;
+
+ &:hover {
+ background: var(--fn-bg-hover, var(--fn-bg-secondary));
+ color: var(--fn-text-primary);
+ border-color: var(--fn-border-primary);
+ }
+}
+
+.siblingLoading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 200px;
+}
+
+.siblingEmpty {
+ min-height: 200px;
+ padding: 12px;
+ color: var(--fn-text-tertiary);
+ font-size: 13px;
+}
+
+.siblingViewer {
+ flex: 1;
+ min-height: 200px;
+ overflow: auto;
+}
+
.createForm {
min-height: 0;
overflow: hidden;
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts
new file mode 100644
index 00000000..47610dec
--- /dev/null
+++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildSkillMarkdown,
+ OCTOP_EMOJI_META_KEY,
+ parseSkillEmojiAndMetadata,
+} from "./SkillDrawer";
+
+describe("SkillDrawer emoji metadata", () => {
+ it("writes octop.emoji into frontmatter from the emoji field", () => {
+ const md = buildSkillMarkdown({
+ name: "demo",
+ description: "A demo skill",
+ emoji: "⚙️",
+ metadata: [],
+ body: "Do things.",
+ });
+ expect(md).toMatch(/emoji:\s*"?⚙️"?/);
+ expect(md).toContain("octop:");
+ });
+
+ it("extracts emoji from flattened metadata and keeps other keys", () => {
+ const { emoji, metadata } = parseSkillEmojiAndMetadata([
+ { key: OCTOP_EMOJI_META_KEY, value: "🔧" },
+ { key: "octop.requires.bins", value: "git" },
+ ]);
+ expect(emoji).toBe("🔧");
+ expect(metadata).toEqual([{ key: "octop.requires.bins", value: "git" }]);
+ });
+});
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx
index a5c997dc..bd9aa1c3 100644
--- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx
+++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx
@@ -1,13 +1,27 @@
-import { useEffect, useState } from "react";
-import { Drawer, Form, Input, Button, Segmented } from "antd";
+import { useCallback, useEffect, useState } from "react";
+import { Drawer, Form, Input, Button, Segmented, Tooltip } from "antd";
import { message } from "@/utils/antdMessage";
-import { MinusCircle, Plus } from "lucide-react";
+import { MinusCircle, PanelLeftOpen, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FormInstance } from "antd";
+import EmojiPicker from "../../../../components/EmojiPicker";
import Markdown from "../../../../components/Markdown/LazyMarkdown";
+import { request } from "../../../../api/request";
import { splitMarkdownFrontmatter } from "../../../../utils/markdown";
+import { withFromWorkspace } from "../../../../utils/fromWorkspace";
+import { useListPanelCollapsed } from "../../../../hooks/useListPanelCollapsed";
import type { SkillDetail } from "../useSkills";
+import {
+ isSkillManifestPath,
+ skillDirectoryPath,
+ skillManifestPath,
+ DEFAULT_SKILL_EMOJI,
+} from "../skillMarkdown";
+import FileViewer from "../../Workspace/components/FileViewer";
+import { getDocKind } from "../../Workspace/utils/docKind";
+import { getMediaKind } from "../../Workspace/utils/mediaKind";
+import { SkillFileTree } from "./SkillFileTree";
import styles from "./SkillDrawer.module.less";
export interface MetadataEntry {
@@ -19,6 +33,8 @@ export interface MetadataEntry {
export interface SkillFormValues {
name: string;
description: string;
+ /** Surfaced as ``metadata.octop.emoji`` in SKILL.md. */
+ emoji: string;
metadata: MetadataEntry[];
body: string;
content?: string;
@@ -26,6 +42,8 @@ export interface SkillFormValues {
path?: string;
}
+export const OCTOP_EMOJI_META_KEY = "octop.emoji";
+
function yamlQuote(value: string): string {
if (!value) return '""';
if (/[:#\n"'{}[\],&*?|>!%@`]/.test(value) || value.trim() !== value) {
@@ -72,6 +90,36 @@ function metadataToYamlLines(
return lines;
}
+/** Split ``octop.emoji`` out of flattened metadata for the dedicated picker. */
+export function parseSkillEmojiAndMetadata(
+ pairs: MetadataEntry[] | undefined,
+ fallback = DEFAULT_SKILL_EMOJI,
+): { emoji: string; metadata: MetadataEntry[] } {
+ let emoji = fallback;
+ const metadata: MetadataEntry[] = [];
+ for (const row of pairs ?? []) {
+ if (row.key.trim() === OCTOP_EMOJI_META_KEY) {
+ const value = row.value.trim();
+ if (value) emoji = value;
+ continue;
+ }
+ metadata.push(row);
+ }
+ return { emoji, metadata };
+}
+
+function withEmojiMetadata(
+ pairs: MetadataEntry[] | undefined,
+ emoji: string,
+): MetadataEntry[] {
+ const rest = (pairs ?? []).filter(
+ (row) => row.key.trim() !== OCTOP_EMOJI_META_KEY,
+ );
+ const value = emoji.trim();
+ if (!value) return rest;
+ return [{ key: OCTOP_EMOJI_META_KEY, value }, ...rest];
+}
+
function buildMetadataObject(
pairs: MetadataEntry[] | undefined,
): Record {
@@ -90,7 +138,9 @@ export function buildSkillMarkdown(values: SkillFormValues): string {
`name: ${yamlQuote(values.name.trim())}`,
`description: ${yamlQuote(values.description.trim())}`,
];
- const meta = buildMetadataObject(values.metadata);
+ const meta = buildMetadataObject(
+ withEmojiMetadata(values.metadata, values.emoji ?? ""),
+ );
if (Object.keys(meta).length > 0) {
lines.push("metadata:");
lines.push(...metadataToYamlLines(meta, 1));
@@ -145,10 +195,14 @@ function parseSkillFormFromDetail(detail: SkillDetail): SkillFormValues {
typeof fm.name === "string" && fm.name.trim() ? fm.name : detail.slug;
const description =
typeof fm.description === "string" ? fm.description : detail.description;
- const metadata = flattenMetadata(fm.metadata);
+ const { emoji, metadata } = parseSkillEmojiAndMetadata(
+ flattenMetadata(fm.metadata),
+ detail.emoji?.trim() || DEFAULT_SKILL_EMOJI,
+ );
return {
name: displayName,
description,
+ emoji,
metadata,
body: detail.body || "",
content: detail.raw,
@@ -163,12 +217,18 @@ function parseSkillFormFromDetail(detail: SkillDetail): SkillFormValues {
type ViewTab = "preview" | "source";
type EditorTab = "form" | "source";
+const FILE_TREE_COLLAPSED_KEY = "octop:skill-drawer-tree-collapsed";
+
interface SkillDrawerProps {
open: boolean;
editingSkill: SkillDetail | null;
form: FormInstance;
onClose: () => void;
onSubmit: (values: SkillFormValues) => void;
+ /** When set, show skill directory file tree (workspace / built-in skills). */
+ agentId?: string | null;
+ /** Agent harness must be running for workspace file/tree APIs. */
+ workspaceReady?: boolean;
}
export function SkillDrawer({
@@ -177,14 +237,39 @@ export function SkillDrawer({
form,
onClose,
onSubmit,
+ agentId,
+ workspaceReady = false,
}: SkillDrawerProps) {
const { t } = useTranslation();
const isCreate = !editingSkill;
const [localEditMode, setLocalEditMode] = useState(false);
const [viewTab, setViewTab] = useState("preview");
const [editorTab, setEditorTab] = useState("form");
-
+ const [selectedFilePath, setSelectedFilePath] = useState(null);
+ const [siblingContent, setSiblingContent] = useState("");
+ const [siblingLoading, setSiblingLoading] = useState(false);
+ const [siblingViewTab, setSiblingViewTab] = useState("preview");
+ const { collapsed: fileTreeCollapsed, toggle: toggleFileTreeCollapsed } =
+ useListPanelCollapsed(FILE_TREE_COLLAPSED_KEY, { defaultCollapsed: true });
+
+ const skillRoot = editingSkill ? skillDirectoryPath(editingSkill) : null;
+ const showFileTree = Boolean(editingSkill && agentId && skillRoot);
const isEdit = !!editingSkill && localEditMode;
+
+ const handleSelectFilePath = useCallback(
+ (path: string) => {
+ if (isEdit && !isSkillManifestPath(path)) {
+ message.warning(t("skills.finishEditBeforeSwitchFile"));
+ return;
+ }
+ setSelectedFilePath(path);
+ },
+ [isEdit, t],
+ );
+
+ const viewingSkillMd =
+ !selectedFilePath || isSkillManifestPath(selectedFilePath);
+
const fieldsEditable = isCreate || isEdit;
useEffect(() => {
@@ -192,12 +277,17 @@ export function SkillDrawer({
setLocalEditMode(false);
setViewTab("preview");
setEditorTab("form");
+ setSelectedFilePath(null);
+ setSiblingContent("");
+ setSiblingViewTab("preview");
return;
}
setLocalEditMode(false);
setViewTab("preview");
setEditorTab("form");
+ setSiblingViewTab("preview");
if (editingSkill) {
+ setSelectedFilePath(skillManifestPath(editingSkill));
const parsed = parseSkillFormFromDetail(editingSkill);
form.setFieldsValue({
...parsed,
@@ -208,15 +298,64 @@ export function SkillDrawer({
});
return;
}
+ setSelectedFilePath(null);
form.setFieldsValue({
name: "",
description: "",
- metadata: [{ key: "octop.emoji", value: "✨" }],
+ emoji: DEFAULT_SKILL_EMOJI,
+ metadata: [],
body: t("skills.newSkillBodyTemplate"),
content: "",
});
}, [editingSkill, form, open, t]);
+ const loadSiblingFile = useCallback(
+ async (path: string) => {
+ if (!agentId || isSkillManifestPath(path)) return;
+ setSiblingLoading(true);
+ try {
+ const data = await request<{ content: string }>(
+ withFromWorkspace(
+ `/agents/${agentId}/workspace/file?path=${encodeURIComponent(
+ path,
+ )}`,
+ ),
+ );
+ setSiblingContent(data.content ?? "");
+ } catch {
+ setSiblingContent("");
+ } finally {
+ setSiblingLoading(false);
+ }
+ },
+ [agentId],
+ );
+
+ useEffect(() => {
+ if (
+ !open ||
+ !selectedFilePath ||
+ viewingSkillMd ||
+ !agentId ||
+ !workspaceReady
+ ) {
+ return;
+ }
+ if (getMediaKind(selectedFilePath) || getDocKind(selectedFilePath)) {
+ setSiblingContent("");
+ setSiblingLoading(false);
+ return;
+ }
+ void loadSiblingFile(selectedFilePath);
+ }, [
+ agentId,
+ loadSiblingFile,
+ open,
+ selectedFilePath,
+ viewingSkillMd,
+ workspaceReady,
+ ]);
+
const resetToViewForm = () => {
if (!editingSkill) return;
const parsed = parseSkillFormFromDetail(editingSkill);
@@ -324,6 +463,20 @@ export function SkillDrawer({
);
+ const emojiField = (
+
+ {fieldsEditable ? (
+
+ ) : (
+
+ )}
+
+ );
+
const metadataFields = (
@@ -471,9 +624,104 @@ export function SkillDrawer({
);
+ const siblingFileName =
+ selectedFilePath?.split("/").filter(Boolean).pop() ?? "";
+ const siblingUsesRichPreview = Boolean(
+ selectedFilePath &&
+ (getMediaKind(selectedFilePath) || getDocKind(selectedFilePath)),
+ );
+
+ const siblingFileBlock = (
+
+
+ {siblingFileName}
+ {!siblingUsesRichPreview ? (
+ setSiblingViewTab(value as ViewTab)}
+ options={[
+ { value: "preview", label: t("skills.viewPreview") },
+ { value: "source", label: t("skills.viewSource") },
+ ]}
+ />
+ ) : null}
+
+ {!workspaceReady ? (
+
+ {t("skills.fileTreeAgentNotReady")}
+
+ ) : agentId && selectedFilePath ? (
+
+ {}}
+ fileLoading={siblingLoading}
+ previewMode={siblingViewTab === "preview"}
+ />
+
+ ) : (
+
—
+ )}
+
+ );
+
+ const mainPanel = (
+
+ );
+
return (
-
+
+ {showFileTree && skillRoot && agentId && !fileTreeCollapsed ? (
+
+ ) : null}
+
+ {showFileTree && fileTreeCollapsed ? (
+
+
+
+
+
+ ) : null}
+ {mainPanel}
+
+
{isCreate ? (
@@ -545,10 +791,13 @@ export function SkillDrawer({
) : (
<>
{t("common.close")}
- {editingSkill?.kind === "workspace" ? (
+ {editingSkill?.kind === "workspace" && viewingSkillMd ? (
{
+ if (editingSkill) {
+ setSelectedFilePath(skillManifestPath(editingSkill));
+ }
setEditorTab("form");
setLocalEditMode(true);
}}
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillFileTree.module.less b/dashboard/src/pages/Agent/Skills/components/SkillFileTree.module.less
new file mode 100644
index 00000000..14a8ece2
--- /dev/null
+++ b/dashboard/src/pages/Agent/Skills/components/SkillFileTree.module.less
@@ -0,0 +1,104 @@
+.treePane {
+ flex-shrink: 0;
+ width: 220px;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-right: 1px solid var(--fn-border-secondary);
+ background: var(--fn-bg-secondary);
+}
+
+.treeHeader {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 40px;
+ padding: 0 8px 0 12px;
+ border-bottom: 1px solid var(--fn-border-secondary);
+}
+
+.treeHeaderTitle {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--fn-text-primary);
+ font-size: 13px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.treeHeaderToggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--fn-text-tertiary);
+ cursor: pointer;
+ transition:
+ background 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ background: var(--fn-bg-hover);
+ color: var(--fn-text-primary);
+ }
+}
+
+.treeScroll {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 8px 10px;
+}
+
+.treeLoading,
+.treeEmpty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px 8px;
+ font-size: 12px;
+ color: var(--fn-text-tertiary);
+}
+
+.treeNodeTitle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ width: 100%;
+}
+
+.treeNodeName {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.treeNodeSize {
+ flex-shrink: 0;
+ font-size: 10px;
+ color: var(--fn-text-tertiary);
+}
+
+.treeFileIcon {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ line-height: 0;
+}
+
+.tree {
+ background: transparent;
+}
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillFileTree.tsx b/dashboard/src/pages/Agent/Skills/components/SkillFileTree.tsx
new file mode 100644
index 00000000..7be30799
--- /dev/null
+++ b/dashboard/src/pages/Agent/Skills/components/SkillFileTree.tsx
@@ -0,0 +1,214 @@
+import { useCallback, useEffect, useState } from "react";
+import { Spin, Tooltip, Tree } from "antd";
+import type { TreeDataNode } from "antd";
+import { Folder, PanelLeftClose } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { message } from "@/utils/antdMessage";
+import { request } from "../../../../api/request";
+import { withFromWorkspace } from "../../../../utils/fromWorkspace";
+import { fileTreeIcon } from "../../../../utils/fileTreeIcon";
+import { workspaceEntryPath } from "../../../../utils/workspacePath";
+import styles from "./SkillFileTree.module.less";
+
+interface FileInfo {
+ path: string;
+ is_dir?: boolean;
+ size?: number;
+}
+
+interface TreeKey {
+ path: string;
+ is_dir: boolean;
+}
+
+function nodeKey(t: TreeKey): string {
+ return `${t.is_dir ? "d" : "f"}:${t.path}`;
+}
+
+function pathFromKey(key: string): TreeKey {
+ const sep = key.indexOf(":");
+ return { is_dir: key[0] === "d", path: key.slice(sep + 1) };
+}
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+function toTreeNodes(infos: FileInfo[]): TreeDataNode[] {
+ const sorted = [...infos].sort((a, b) => {
+ const ad = a.is_dir ? 0 : 1;
+ const bd = b.is_dir ? 0 : 1;
+ if (ad !== bd) return ad - bd;
+ const an = (
+ a.path.split("/").filter(Boolean).pop() || a.path
+ ).toLowerCase();
+ const bn = (
+ b.path.split("/").filter(Boolean).pop() || b.path
+ ).toLowerCase();
+ return an.localeCompare(bn);
+ });
+
+ return sorted.map((info) => {
+ const fullPath = workspaceEntryPath(info.path);
+ const fname = fullPath.split("/").filter(Boolean).pop() || fullPath;
+ const key = nodeKey({ path: fullPath, is_dir: !!info.is_dir });
+ return {
+ key,
+ title: (
+
+ {info.is_dir ? (
+
+ ) : (
+
+ {fileTreeIcon(fullPath)}
+
+ )}
+ {fname}
+ {info.size != null && !info.is_dir ? (
+ {formatSize(info.size)}
+ ) : null}
+
+ ),
+ isLeaf: !info.is_dir,
+ children: info.is_dir ? [] : undefined,
+ } as TreeDataNode;
+ });
+}
+
+interface SkillFileTreeProps {
+ agentId: string;
+ skillRoot: string;
+ selectedPath: string | null;
+ onSelectPath: (path: string) => void;
+ onCollapse: () => void;
+ workspaceReady: boolean;
+ selectionDisabled?: boolean;
+}
+
+export function SkillFileTree({
+ agentId,
+ skillRoot,
+ selectedPath,
+ onSelectPath,
+ onCollapse,
+ workspaceReady,
+ selectionDisabled = false,
+}: SkillFileTreeProps) {
+ const { t } = useTranslation();
+ const [treeData, setTreeData] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [expandedKeys, setExpandedKeys] = useState([]);
+
+ const fetchTree = useCallback(
+ async (path: string) =>
+ request(
+ withFromWorkspace(
+ `/agents/${agentId}/workspace/tree?path=${encodeURIComponent(path)}`,
+ ),
+ ),
+ [agentId],
+ );
+
+ const refreshRoot = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await fetchTree(skillRoot);
+ setTreeData(toTreeNodes(data));
+ } catch {
+ setTreeData([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [fetchTree, skillRoot]);
+
+ useEffect(() => {
+ if (!workspaceReady) {
+ setExpandedKeys([]);
+ setTreeData([]);
+ setLoading(false);
+ return;
+ }
+ setExpandedKeys([]);
+ setTreeData([]);
+ void refreshRoot();
+ }, [agentId, skillRoot, refreshRoot, workspaceReady]);
+
+ const onLoadData = async (node: TreeDataNode): Promise => {
+ const { path, is_dir } = pathFromKey(String(node.key));
+ if (!is_dir) return;
+ try {
+ const data = await fetchTree(path);
+ const children = toTreeNodes(data);
+ const replace = (nodes: TreeDataNode[]): TreeDataNode[] =>
+ nodes.map((n) =>
+ n.key === node.key
+ ? { ...n, children }
+ : n.children
+ ? { ...n, children: replace(n.children) }
+ : n,
+ );
+ setTreeData((current) => replace(current));
+ } catch {
+ /* ignore — tree node stays empty */
+ }
+ };
+
+ const selectedKey = selectedPath
+ ? nodeKey({ path: selectedPath, is_dir: false })
+ : undefined;
+
+ return (
+
+
+
+ {t("skills.fileTreeTitle")}
+
+
+
+
+
+
+
+
+ {!workspaceReady ? (
+
+ {t("skills.fileTreeAgentNotReady")}
+
+ ) : loading ? (
+
+
+
+ ) : treeData.length === 0 ? (
+
{t("skills.fileTreeEmpty")}
+ ) : (
+
setExpandedKeys(keys as string[])}
+ selectedKeys={selectedKey ? [selectedKey] : []}
+ onSelect={(_keys, info) => {
+ if (selectionDisabled) {
+ message.warning(t("skills.finishEditBeforeSwitchFile"));
+ return;
+ }
+ const { path, is_dir } = pathFromKey(String(info.node.key));
+ if (is_dir) return;
+ onSelectPath(path);
+ }}
+ className={styles.tree}
+ />
+ )}
+
+
+ );
+}
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.test.tsx b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.test.tsx
index 43faa0bd..cf8f6beb 100644
--- a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.test.tsx
+++ b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.test.tsx
@@ -186,7 +186,9 @@ describe(" local zip import", () => {
'input[type="file"]',
) as HTMLInputElement;
setInputFiles(fileInput, [zipFile]);
- await user.click(screen.getByText("skills.removeZip"));
+ const removeButtons = screen.getAllByText("skills.removeZip");
+ // Click either remove button
+ await user.click(removeButtons[0]);
expect(
screen.getByRole("button", { name: "skills.importSkills" }),
).toBeDisabled();
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx
index 28fb2265..c19a253d 100644
--- a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx
+++ b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx
@@ -59,6 +59,7 @@ export function SkillImportModal({
const [zipError, setZipError] = useState("");
const [overwrite, setOverwrite] = useState(false);
const [parsing, setParsing] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef(null);
useEffect(() => {
@@ -70,9 +71,24 @@ export function SkillImportModal({
setZipError("");
setOverwrite(false);
setParsing(false);
+ setIsDragging(false);
}
}, [open]);
+ const handleFileSelect = (file: File | null) => {
+ if (!file) {
+ setZipFile(null);
+ return;
+ }
+ if (!file.name.toLowerCase().endsWith(".zip")) {
+ setZipFile(null);
+ setZipError(t("skills.zipOnly"));
+ return;
+ }
+ setZipError("");
+ setZipFile(file);
+ };
+
const handleUrlChange = (value: string) => {
setImportUrl(value);
const trimmed = value.trim();
@@ -133,6 +149,28 @@ export function SkillImportModal({
? !importUrl.trim() || !!importUrlError
: !zipFile || !!zipError);
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ if (busy) return;
+ setIsDragging(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ if (busy) return;
+
+ const files = e.dataTransfer.files;
+ if (files && files.length > 0) {
+ handleFileSelect(files[0]);
+ }
+ };
+
return (
+
!busy && fileInputRef.current?.click()}
+ >
+
+
+ {zipFile ? t("skills.zipSelected", { name: zipFile.name }) : t("skills.zipDragDropHint")}
+
+ {zipFile ? (
+
{
+ e.stopPropagation();
+ setZipFile(null);
+ setZipError("");
+ }}
+ >
+ {t("skills.removeZip")}
+
+ ) : null}
+
+
{
const next = event.target.files?.[0] ?? null;
event.target.value = "";
- setZipError("");
- if (!next) {
- setZipFile(null);
- return;
- }
- if (!next.name.toLowerCase().endsWith(".zip")) {
- setZipFile(null);
- setZipError(t("skills.zipOnly"));
- return;
- }
- setZipFile(next);
+ handleFileSelect(next);
}}
/>
+
}
@@ -268,6 +329,7 @@ export function SkillImportModal({
) : null}
+
{zipError ? (
{zipError}
) : null}
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx b/dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx
index f8511ac7..128803c2 100644
--- a/dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx
+++ b/dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx
@@ -83,6 +83,7 @@ export default function SkillsTabs({ agentId }: SkillsTabsProps) {
agentId ? (
diff --git a/dashboard/src/pages/Agent/Skills/index.module.less b/dashboard/src/pages/Agent/Skills/index.module.less
index c735c6d5..b660d39b 100644
--- a/dashboard/src/pages/Agent/Skills/index.module.less
+++ b/dashboard/src/pages/Agent/Skills/index.module.less
@@ -965,6 +965,7 @@
align-items: center;
gap: 12px;
flex-wrap: wrap;
+ margin-top: 16px;
}
.zipFileName {
@@ -972,3 +973,47 @@
font-size: 13px;
word-break: break-all;
}
+
+.zipDragDrop {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 32px 24px;
+ border: 2px dashed var(--fn-border-input);
+ border-radius: var(--fn-radius-md);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ background-color: var(--fn-bg-tertiary);
+
+ &:hover {
+ border-color: var(--fn-color-brand);
+ background-color: color-mix(in srgb, var(--fn-color-brand) 5%, var(--fn-bg-tertiary));
+ }
+}
+
+.zipDragDropActive {
+ border-color: var(--fn-color-brand);
+ background-color: color-mix(in srgb, var(--fn-color-brand) 10%, var(--fn-bg-tertiary));
+ transform: scale(1.01);
+}
+
+.zipDragDropHasFile {
+ border-style: solid;
+ border-color: var(--fn-color-brand);
+}
+
+.zipDragDropIcon {
+ color: var(--fn-text-tertiary);
+ opacity: 0.8;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.zipDragDropText {
+ color: var(--fn-text-secondary);
+ font-size: 14px;
+ text-align: center;
+}
diff --git a/dashboard/src/pages/Agent/Skills/skillMarkdown.test.ts b/dashboard/src/pages/Agent/Skills/skillMarkdown.test.ts
new file mode 100644
index 00000000..0090a904
--- /dev/null
+++ b/dashboard/src/pages/Agent/Skills/skillMarkdown.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { buildSkillMarkdown } from "./components/SkillDrawer";
+import {
+ isSkillManifestPath,
+ parseSkillPreviewFromMarkdown,
+ skillDirectoryPath,
+ skillManifestPath,
+} from "./skillMarkdown";
+
+describe("parseSkillPreviewFromMarkdown", () => {
+ it("reads name, description, and octop emoji from SKILL.md", () => {
+ const md = buildSkillMarkdown({
+ name: "My Skill",
+ description: "Does work",
+ emoji: "🛠️",
+ metadata: [],
+ body: "Body",
+ });
+ expect(parseSkillPreviewFromMarkdown(md, "my-skill")).toEqual({
+ emoji: "🛠️",
+ name: "My Skill",
+ description: "Does work",
+ });
+ });
+});
+
+describe("isSkillManifestPath", () => {
+ it("matches SKILL.md paths", () => {
+ expect(isSkillManifestPath("/skills/foo/SKILL.md")).toBe(true);
+ expect(isSkillManifestPath("notes.txt")).toBe(false);
+ });
+});
+
+describe("skillDirectoryPath", () => {
+ it("maps workspace and builtin roots", () => {
+ expect(skillDirectoryPath({ kind: "workspace", slug: "demo" })).toBe(
+ "/skills/demo",
+ );
+ expect(skillDirectoryPath({ kind: "builtin", slug: "demo" })).toBe(
+ "/_builtin_skills/demo",
+ );
+ });
+
+ it("builds manifest path", () => {
+ expect(skillManifestPath({ kind: "workspace", slug: "demo" })).toBe(
+ "/skills/demo/SKILL.md",
+ );
+ });
+});
diff --git a/dashboard/src/pages/Agent/Skills/skillMarkdown.ts b/dashboard/src/pages/Agent/Skills/skillMarkdown.ts
new file mode 100644
index 00000000..c17b38ff
--- /dev/null
+++ b/dashboard/src/pages/Agent/Skills/skillMarkdown.ts
@@ -0,0 +1,53 @@
+import { splitMarkdownFrontmatter } from "../../../utils/markdown";
+import type { SkillDetail } from "./useSkills";
+
+export const DEFAULT_SKILL_EMOJI = "✨";
+
+function yamlTopLevel(block: string, key: string): string {
+ const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m");
+ const match = block.match(re);
+ if (!match) return "";
+ return (match[1] || "").trim().replace(/^["']|["']$/g, "");
+}
+
+/** Parse display fields from raw SKILL.md for list previews (e.g. expert template). */
+export function parseSkillPreviewFromMarkdown(
+ content: string,
+ slug: string,
+): { emoji: string; name: string; description: string } {
+ const { raw } = splitMarkdownFrontmatter(content);
+ const fm = raw ?? "";
+ const name = yamlTopLevel(fm, "name") || slug;
+ const description = yamlTopLevel(fm, "description") || "";
+ let emoji = DEFAULT_SKILL_EMOJI;
+ const emojiMatch = fm.match(/octop:\s*\n\s*emoji:\s*(.+)/);
+ if (emojiMatch?.[1]) {
+ const value = emojiMatch[1].trim().replace(/^["']|["']$/g, "");
+ if (value) emoji = value;
+ }
+ return { emoji, name, description };
+}
+
+export function skillDirectoryPath(
+ detail: Pick
,
+): string {
+ return detail.kind === "builtin"
+ ? `/_builtin_skills/${detail.slug}`
+ : `/skills/${detail.slug}`;
+}
+
+export function isSkillManifestPath(path: string): boolean {
+ const normalized = path.replace(/\\/g, "/");
+ return (
+ normalized.endsWith("/SKILL.md") ||
+ normalized === "SKILL.md" ||
+ normalized.endsWith("/skill.md") ||
+ normalized === "skill.md"
+ );
+}
+
+export function skillManifestPath(
+ detail: Pick,
+): string {
+ return `${skillDirectoryPath(detail)}/SKILL.md`;
+}
diff --git a/dashboard/src/pages/Chat/chatAgentCard.partial.less b/dashboard/src/pages/Chat/chatAgentCard.partial.less
index 94903df1..141f6678 100644
--- a/dashboard/src/pages/Chat/chatAgentCard.partial.less
+++ b/dashboard/src/pages/Chat/chatAgentCard.partial.less
@@ -44,6 +44,21 @@
font-weight: 500;
color: var(--fn-text-primary);
line-height: 1.35;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sharedExpertHint {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ color: var(--fn-text-tertiary);
+ border-radius: 999px;
}
.agentCardDesc {
diff --git a/dashboard/src/pages/Chat/chatContextChips.partial.less b/dashboard/src/pages/Chat/chatContextChips.partial.less
index 9fd4f44e..b9d9c817 100644
--- a/dashboard/src/pages/Chat/chatContextChips.partial.less
+++ b/dashboard/src/pages/Chat/chatContextChips.partial.less
@@ -139,6 +139,30 @@
}
}
+.contextChipKnowledge {
+ border-color: rgba(14, 165, 233, 0.24);
+ background: rgba(14, 165, 233, 0.08);
+
+ .contextChipIcon {
+ border-radius: 50%;
+ background: rgba(14, 165, 233, 0.14);
+ color: #0284c7;
+ }
+
+ .contextChipLabel {
+ color: #0369a1;
+ }
+
+ .contextChipRemove {
+ color: rgba(3, 105, 161, 0.55);
+
+ &:hover {
+ color: #0369a1;
+ background: rgba(14, 165, 233, 0.12);
+ }
+ }
+}
+
.contextChipExpert {
border-color: rgba(99, 102, 241, 0.22);
background: rgba(99, 102, 241, 0.08);
diff --git a/dashboard/src/pages/Chat/chatInputCore.partial.less b/dashboard/src/pages/Chat/chatInputCore.partial.less
index 5bba4031..d25ae392 100644
--- a/dashboard/src/pages/Chat/chatInputCore.partial.less
+++ b/dashboard/src/pages/Chat/chatInputCore.partial.less
@@ -645,6 +645,12 @@
border-radius: var(--fn-radius-sm);
overflow: hidden;
border: 1px solid var(--fn-border-secondary);
+ line-height: 0;
+
+ :global(.ant-image) {
+ display: block;
+ line-height: 0;
+ }
}
.attachmentPreviewCard {
@@ -682,8 +688,8 @@
}
.imagePreviewThumb {
- width: 100%;
- height: 100%;
+ width: 64px;
+ height: 64px;
object-fit: cover;
display: block;
}
diff --git a/dashboard/src/pages/Chat/chatInputPickers.partial.less b/dashboard/src/pages/Chat/chatInputPickers.partial.less
index a6b7e26c..178e719e 100644
--- a/dashboard/src/pages/Chat/chatInputPickers.partial.less
+++ b/dashboard/src/pages/Chat/chatInputPickers.partial.less
@@ -422,6 +422,19 @@
background: var(--fn-bg-subtle, #f3f4f6);
}
+.knowledgePickerAvatar {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ overflow: hidden;
+ color: #0284c7;
+ background: rgba(14, 165, 233, 0.12);
+}
+
.shortcutPickerIcon {
flex-shrink: 0;
width: 36px;
diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less
index 51ab5f46..9631c0c7 100644
--- a/dashboard/src/pages/Chat/chatMessages.partial.less
+++ b/dashboard/src/pages/Chat/chatMessages.partial.less
@@ -1027,6 +1027,12 @@
color: var(--fn-text-primary);
background: var(--fn-bg-hover);
}
+
+ .msgActionBtn:disabled,
+ .msgActionBtn:disabled:hover {
+ cursor: not-allowed;
+ opacity: 0.35;
+ }
}
/* Reveal when the user bubble is hovered/focused, not only the tiny buttons. */
diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx
index 46e774b2..898dfa8b 100644
--- a/dashboard/src/pages/Chat/components/ChatInput.tsx
+++ b/dashboard/src/pages/Chat/components/ChatInput.tsx
@@ -16,6 +16,7 @@ import SlashCommandMenu from "./SlashCommandMenu";
import { agentChatApi } from "../../../api/modules/agentChat";
import type { ChatAttachment } from "../hooks/useChat";
import type { ResolvedModel } from "../../../api/types";
+import type { KnowledgeBase } from "../../../api/modules/knowledgeBases";
import type { SkillSpec } from "../../Agent/Skills/useSkills";
import type { ChatAgentOption } from "./ExpertAgentAvatar";
import MentionPickerMenu from "./MentionPickerMenu";
@@ -27,7 +28,11 @@ import { useKeyboardOffset } from "../../../hooks/useKeyboardOffset";
import { useChatAttachments } from "../hooks/useChatAttachments";
import { useSlashMentionInput } from "../hooks/useSlashMentionInput";
import { stripThinkingTags } from "../utils/chatAttachments";
-import { readInputDraft, writeInputDraft } from "../hooks/chatStore";
+import {
+ consumePendingPrefillAttachments,
+ readInputDraft,
+ writeInputDraft,
+} from "../hooks/chatStore";
import {
buildComposerContext,
resolveTurnModelRef,
@@ -41,6 +46,7 @@ import styles from "../index.module.less";
/** Imperative handle exposed via ref for programmatic text injection. */
export interface ChatInputHandle {
setPrefillText: (text: string) => void;
+ setPrefillComposer: (text: string, attachments?: ChatAttachment[]) => void;
}
interface ChatInputProps {
@@ -81,6 +87,9 @@ interface ChatInputProps {
}[];
selectedConnectors?: string[];
onConnectorsChange?: (names: string[]) => void;
+ availableKnowledgeBases?: KnowledgeBase[];
+ selectedKnowledgeBaseIds?: string[];
+ onKnowledgeBaseIdsChange?: (ids: string[]) => void;
availableSkills?: SkillSpec[];
selectedSkills?: string[];
onSkillsChange?: (names: string[]) => void;
@@ -124,6 +133,9 @@ const ChatInput = forwardRef(
availableConnectors,
selectedConnectors = [],
onConnectorsChange,
+ availableKnowledgeBases,
+ selectedKnowledgeBaseIds = [],
+ onKnowledgeBaseIdsChange,
availableSkills,
selectedSkills = [],
onSkillsChange,
@@ -167,24 +179,70 @@ const ChatInput = forwardRef(
transcribing,
toggle: toggleVoice,
} = useVoiceInput(handleVoiceText);
+ const textareaRef = useRef(null);
+ const {
+ attachments,
+ uploading,
+ dragOver,
+ fileInputRef,
+ acceptAttr,
+ handleFileSelect,
+ handleFileChange,
+ removeAttachment,
+ clearAttachments,
+ restoreAttachments,
+ handlePaste,
+ handleDragEnter,
+ handleDragLeave,
+ handleDragOver,
+ handleDrop,
+ } = useChatAttachments(agentId);
// Expose an imperative handle so the parent can push a new prefill without
// triggering a prop change that would cause a re-render cascade.
- useImperativeHandle(ref, () => ({
- setPrefillText: (newText: string) => {
- userHasEditedRef.current = false;
- ignoreInitialTextRef.current = null;
- prevInitialTextRef.current = newText;
- setText(newText);
- setTimeout(() => {
- const el = textareaRef.current;
- if (el) {
- el.focus();
- el.setSelectionRange(el.value.length, el.value.length);
+ useImperativeHandle(
+ ref,
+ () => ({
+ setPrefillText: (newText: string) => {
+ userHasEditedRef.current = false;
+ ignoreInitialTextRef.current = null;
+ prevInitialTextRef.current = newText;
+ setText(newText);
+ setTimeout(() => {
+ const el = textareaRef.current;
+ if (el) {
+ el.focus();
+ el.setSelectionRange(el.value.length, el.value.length);
+ }
+ }, 50);
+ },
+ setPrefillComposer: (
+ newText: string,
+ nextAttachments?: ChatAttachment[],
+ ) => {
+ userHasEditedRef.current = false;
+ ignoreInitialTextRef.current = null;
+ prevInitialTextRef.current = newText;
+ setText(newText);
+ if (nextAttachments && nextAttachments.length > 0) {
+ restoreAttachments(
+ nextAttachments.map((attachment) => ({ ...attachment })),
+ );
+ } else {
+ clearAttachments();
}
- }, 50);
- },
- }));
+ setTimeout(() => {
+ const el = textareaRef.current;
+ if (el) {
+ el.focus();
+ el.setSelectionRange(el.value.length, el.value.length);
+ }
+ }, 50);
+ },
+ }),
+ [clearAttachments, restoreAttachments],
+ );
+
// When the parent passes a non-empty initialText after mount (e.g. navigated
// from cron-jobs), update the input value and move the cursor to the end.
// Only fires when initialText actually changes AND the user hasn't started
@@ -232,7 +290,13 @@ const ChatInput = forwardRef(
ignoreInitialTextRef.current = null;
prevInitialTextRef.current = "";
setText(initialText || readInputDraft(agentId, threadId));
- }, [agentId, threadId, initialText]);
+ const pendingAttachments = consumePendingPrefillAttachments();
+ if (pendingAttachments.length > 0) {
+ restoreAttachments(pendingAttachments);
+ } else {
+ clearAttachments();
+ }
+ }, [agentId, threadId, initialText, clearAttachments, restoreAttachments]);
// Persist draft while typing so leaving /chat and returning keeps content.
useEffect(() => {
@@ -242,24 +306,6 @@ const ChatInput = forwardRef(
}, 250);
return () => window.clearTimeout(timer);
}, [text, agentId, threadId]);
- const {
- attachments,
- uploading,
- dragOver,
- fileInputRef,
- acceptAttr,
- handleFileSelect,
- handleFileChange,
- removeAttachment,
- clearAttachments,
- restoreAttachments,
- handlePaste,
- handleDragEnter,
- handleDragLeave,
- handleDragOver,
- handleDrop,
- } = useChatAttachments(agentId);
- const textareaRef = useRef(null);
const submitRef = useRef<() => void>(() => {});
const MIN_TEXTAREA_HEIGHT = isMobile ? 42 : 78;
@@ -363,6 +409,7 @@ const ChatInput = forwardRef(
composerContext: buildComposerContext({
skills: selectedSkills,
connectors: selectedConnectors,
+ knowledgeBaseIds: selectedKnowledgeBaseIds,
targetAgents: selectedTargetAgents,
selectedModel,
reasoningMode,
@@ -416,6 +463,7 @@ const ChatInput = forwardRef(
if (ctx) {
onSkillsChange?.(ctx.skills ?? []);
onConnectorsChange?.(ctx.connectors ?? []);
+ onKnowledgeBaseIdsChange?.(ctx.knowledgeBaseIds ?? []);
onTargetAgentsChange?.(ctx.targetAgents ?? []);
if (ctx.model !== undefined) {
onModelChange?.(ctx.model);
@@ -460,6 +508,7 @@ const ChatInput = forwardRef(
t,
onSkillsChange,
onConnectorsChange,
+ onKnowledgeBaseIdsChange,
onTargetAgentsChange,
onModelChange,
onReasoningChange,
@@ -564,10 +613,13 @@ const ChatInput = forwardRef(
selectedModel={selectedModel}
availableSkills={availableSkills}
availableConnectors={availableConnectors}
+ availableKnowledgeBases={availableKnowledgeBases}
availableAgents={availableAgents}
onRemoveAttachment={removeAttachment}
onSkillsChange={onSkillsChange}
onConnectorsChange={onConnectorsChange}
+ selectedKnowledgeBaseIds={selectedKnowledgeBaseIds}
+ onKnowledgeBaseIdsChange={onKnowledgeBaseIdsChange}
onTargetAgentsChange={onTargetAgentsChange}
onModelChange={onModelChange}
/>
@@ -696,6 +748,9 @@ const ChatInput = forwardRef(
availableConnectors={availableConnectors}
selectedConnectors={selectedConnectors}
onConnectorsChange={onConnectorsChange}
+ availableKnowledgeBases={availableKnowledgeBases}
+ selectedKnowledgeBaseIds={selectedKnowledgeBaseIds}
+ onKnowledgeBaseIdsChange={onKnowledgeBaseIdsChange}
availableSkills={availableSkills}
selectedSkills={selectedSkills}
onSkillsChange={onSkillsChange}
diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx
index 983c1428..f74ab74e 100644
--- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx
+++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx
@@ -16,6 +16,7 @@ import {
Cpu,
Brain,
GraduationCap,
+ BookOpen,
MoreHorizontal,
Check,
ChevronLeft,
@@ -23,6 +24,7 @@ import {
} from "lucide-react";
import { Tooltip, Popover, Drawer } from "antd";
import type { ResolvedModel } from "../../../api/types";
+import type { KnowledgeBase } from "../../../api/modules/knowledgeBases";
import type { SkillSpec } from "../../Agent/Skills/useSkills";
import type { ChatAgentOption } from "./ExpertAgentAvatar";
import {
@@ -34,6 +36,7 @@ import ContextWindowRing from "./ContextWindowRing";
import SkillPickerPopover from "./SkillPickerPopover";
import ExpertPickerPopover from "./ExpertPickerPopover";
import ConnectorPickerPopover from "./ConnectorPickerPopover";
+import KnowledgePickerPopover from "./KnowledgePickerPopover";
import SlashCommandMenu from "./SlashCommandMenu";
import type { SlashMenuGroup } from "../../../utils/slashCategories";
import type { SlashMenuItem } from "../hooks/useSlashMentionInput";
@@ -42,7 +45,13 @@ import { isSttAvailable } from "../../../hooks/useVoiceInput";
import { resolveTurnModelOverride } from "../utils/chatMessages";
import styles from "../index.module.less";
-type MobilePickerKey = "model" | "connector" | "skill" | "expert" | "shortcut";
+type MobilePickerKey =
+ | "model"
+ | "connector"
+ | "knowledge"
+ | "skill"
+ | "expert"
+ | "shortcut";
// These browser APIs never change at runtime — compute once.
const _sttAvailable = isSttAvailable();
@@ -84,6 +93,9 @@ interface ChatInputActionsRowProps {
}[];
selectedConnectors?: string[];
onConnectorsChange?: (names: string[]) => void;
+ availableKnowledgeBases?: KnowledgeBase[];
+ selectedKnowledgeBaseIds?: string[];
+ onKnowledgeBaseIdsChange?: (ids: string[]) => void;
availableSkills?: SkillSpec[];
selectedSkills?: string[];
onSkillsChange?: (names: string[]) => void;
@@ -131,6 +143,9 @@ export default function ChatInputActionsRow({
availableConnectors,
selectedConnectors = [],
onConnectorsChange,
+ availableKnowledgeBases,
+ selectedKnowledgeBaseIds = [],
+ onKnowledgeBaseIdsChange,
availableSkills,
selectedSkills = [],
onSkillsChange,
@@ -153,6 +168,7 @@ export default function ChatInputActionsRow({
const [skillPickerOpen, setSkillPickerOpen] = useState(false);
const [expertPickerOpen, setExpertPickerOpen] = useState(false);
const [connectorPickerOpen, setConnectorPickerOpen] = useState(false);
+ const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false);
const [shortcutOpen, setShortcutOpen] = useState(false);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [reasoningModelRef, setReasoningModelRef] = useState(
@@ -194,6 +210,9 @@ export default function ChatInputActionsRow({
const showConnectorPicker = Boolean(
availableConnectors && onConnectorsChange,
);
+ const showKnowledgePicker = Boolean(
+ availableKnowledgeBases && onKnowledgeBaseIdsChange,
+ );
const showSkillPicker = Boolean(availableSkills && onSkillsChange);
const showExpertPicker = Boolean(
availableExperts && onTargetAgentsChange && availableExperts.length > 0,
@@ -201,12 +220,14 @@ export default function ChatInputActionsRow({
const showShortcutPicker = true;
const showOverflowMenu =
showConnectorPicker ||
+ showKnowledgePicker ||
showSkillPicker ||
showExpertPicker ||
showShortcutPicker;
const overflowBadgeCount =
selectedConnectors.length +
+ selectedKnowledgeBaseIds.length +
selectedSkills.length +
selectedTargetAgents.length;
@@ -223,6 +244,7 @@ export default function ChatInputActionsRow({
const mobilePickerTitle: Record = {
model: t("chat.selectModel", "Select model"),
connector: t("connectors.chatPicker"),
+ knowledge: t("chat.knowledgePicker"),
skill: t("chat.skillPicker"),
expert: t("chat.expertPicker"),
shortcut: t("shortcut.title", "快捷指令"),
@@ -466,6 +488,26 @@ export default function ChatInputActionsRow({
)}
+ {showKnowledgePicker && (
+ openMobilePicker("knowledge")}
+ >
+
+
+ {t("chat.knowledgePicker")}
+
+
+ {selectedKnowledgeBaseIds.length > 0 && (
+
+ {selectedKnowledgeBaseIds.length}
+
+ )}
+
+
+
+ )}
{showSkillPicker && (
);
+ case "knowledge":
+ return (
+
+ );
case "skill":
return (
)}
+ {showKnowledgePicker && (
+ setKnowledgePickerOpen(false)}
+ />
+ }
+ >
+
+ 0
+ ? styles.secondaryBtnActive
+ : ""
+ }`}
+ type="button"
+ >
+
+ {selectedKnowledgeBaseIds.length > 0 && (
+
+ {selectedKnowledgeBaseIds.length}
+
+ )}
+
+
+
+ )}
{showSkillPicker && (
void;
onSkillsChange?: (names: string[]) => void;
onConnectorsChange?: (names: string[]) => void;
+ onKnowledgeBaseIdsChange?: (ids: string[]) => void;
onTargetAgentsChange?: (ids: string[]) => void;
onModelChange?: (model: string | null) => void;
}
+function ComposerImagePreview({
+ url,
+ alt,
+ className,
+}: {
+ url: string;
+ alt: string;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ const { src, loadState } = useAuthImageSrc(url, alt);
+
+ if (loadState === "loading") {
+ return (
+
+ );
+ }
+
+ if (loadState === "error" || !src) {
+ return (
+
+ {t("chat.imageLoadFailed")}
+
+ );
+ }
+
+ return (
+
+ );
+}
+
export default function ChatInputPreviewBar({
attachments,
uploading,
selectedSkills,
selectedConnectors,
+ selectedKnowledgeBaseIds,
selectedTargetAgents,
availableSkills,
availableConnectors,
+ availableKnowledgeBases,
availableAgents,
onRemoveAttachment,
onSkillsChange,
onConnectorsChange,
+ onKnowledgeBaseIdsChange,
onTargetAgentsChange,
selectedModel,
onModelChange,
@@ -60,34 +126,46 @@ export default function ChatInputPreviewBar({
attachments.length > 0 ||
uploading ||
selectedConnectors.length > 0 ||
+ selectedKnowledgeBaseIds.length > 0 ||
selectedSkills.length > 0 ||
selectedTargetAgents.length > 0 ||
showModelChip;
if (!hasContent) return null;
+ const imageAttachments = attachments.filter(
+ (attachment) => attachment.kind === "image",
+ );
+
return (
+ {imageAttachments.length > 0 ? (
+
+ {attachments.map((attachment, idx) =>
+ attachment.kind === "image" ? (
+
+
+ onRemoveAttachment(idx)}
+ type="button"
+ >
+
+
+
+ ) : null,
+ )}
+
+ ) : null}
{attachments.map((attachment, idx) =>
- attachment.kind === "image" ? (
-
-
-
onRemoveAttachment(idx)}
- type="button"
- >
-
-
-
- ) : (
+ attachment.kind === "image" ? null : (
);
})}
+ {availableKnowledgeBases &&
+ onKnowledgeBaseIdsChange &&
+ selectedKnowledgeBaseIds.map((id) => {
+ const knowledgeBase = availableKnowledgeBases.find(
+ (base) => base.id === id,
+ );
+ if (!knowledgeBase) return null;
+ return (
+
+ onKnowledgeBaseIdsChange(
+ selectedKnowledgeBaseIds.filter((baseId) => baseId !== id),
+ )
+ }
+ />
+ );
+ })}
{onTargetAgentsChange &&
selectedTargetAgents.map((id) => {
const a = availableAgents.find((x) => x.agent_id === id);
diff --git a/dashboard/src/pages/Chat/components/ContextChip.tsx b/dashboard/src/pages/Chat/components/ContextChip.tsx
index fa71fc4f..b815f4e8 100644
--- a/dashboard/src/pages/Chat/components/ContextChip.tsx
+++ b/dashboard/src/pages/Chat/components/ContextChip.tsx
@@ -2,11 +2,17 @@ import type { ReactNode } from "react";
import { X } from "lucide-react";
import styles from "../index.module.less";
-export type ContextChipVariant = "skill" | "connector" | "expert" | "model";
+export type ContextChipVariant =
+ | "skill"
+ | "connector"
+ | "knowledge"
+ | "expert"
+ | "model";
const variantClass: Record = {
skill: styles.contextChipSkill,
connector: styles.contextChipConnector,
+ knowledge: styles.contextChipKnowledge,
expert: styles.contextChipExpert,
model: styles.contextChipModel,
};
diff --git a/dashboard/src/pages/Chat/components/ExpertAgentAvatar.tsx b/dashboard/src/pages/Chat/components/ExpertAgentAvatar.tsx
index 1167728c..fd9585ef 100644
--- a/dashboard/src/pages/Chat/components/ExpertAgentAvatar.tsx
+++ b/dashboard/src/pages/Chat/components/ExpertAgentAvatar.tsx
@@ -6,6 +6,9 @@ export interface ChatAgentOption {
name: string;
icon_name?: string | null;
color?: string | null;
+ is_shared?: boolean;
+ is_owner?: boolean;
+ owner_username?: string | null;
}
interface ExpertAgentAvatarProps {
diff --git a/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx b/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx
index 57813aab..e253a2bf 100644
--- a/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx
+++ b/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx
@@ -2,10 +2,12 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { GraduationCap } from "lucide-react";
+import { Tag } from "antd";
import SearchablePickerPanel, {
pickerStyles,
} from "../../../components/ChatPicker/SearchablePickerPanel";
import ExpertAgentAvatar, { type ChatAgentOption } from "./ExpertAgentAvatar";
+import { isSharedExpertViewer } from "../../../utils/sharedExpert";
import styles from "../index.module.less";
export type { ChatAgentOption };
@@ -70,6 +72,15 @@ export default function ExpertPickerPopover({
/>
{agent.name}
+ {agent.is_shared && (
+
+ {isSharedExpertViewer(agent)
+ ? t("experts.share.fromOwner", {
+ name: agent.owner_username,
+ })
+ : t("experts.share.badge")}
+
+ )}
);
diff --git a/dashboard/src/pages/Chat/components/KnowledgePickerPopover.tsx b/dashboard/src/pages/Chat/components/KnowledgePickerPopover.tsx
new file mode 100644
index 00000000..192f9548
--- /dev/null
+++ b/dashboard/src/pages/Chat/components/KnowledgePickerPopover.tsx
@@ -0,0 +1,97 @@
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { Switch } from "antd";
+import { Settings2 } from "lucide-react";
+import SearchablePickerPanel, {
+ pickerStyles,
+} from "../../../components/ChatPicker/SearchablePickerPanel";
+import type { KnowledgeBase } from "../../../api/modules/knowledgeBases";
+import { useCurrentUser } from "../../../hooks/useCurrentUser";
+import { knowledgeIconForName } from "../../KnowledgeBases/knowledgeIcons";
+import styles from "../index.module.less";
+
+interface KnowledgePickerPopoverProps {
+ knowledgeBases: KnowledgeBase[];
+ selectedKnowledgeBaseIds: string[];
+ onKnowledgeBaseIdsChange: (ids: string[]) => void;
+ onNavigateAway?: () => void;
+}
+
+export default function KnowledgePickerPopover({
+ knowledgeBases,
+ selectedKnowledgeBaseIds,
+ onKnowledgeBaseIdsChange,
+ onNavigateAway,
+}: KnowledgePickerPopoverProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const currentUserId = useCurrentUser()?.id ?? null;
+
+ const filterFn = useCallback(
+ (knowledgeBase: KnowledgeBase, query: string) =>
+ knowledgeBase.name.toLowerCase().includes(query) ||
+ knowledgeBase.description.toLowerCase().includes(query),
+ [],
+ );
+
+ return (
+ }
+ footerLabel={t("chat.manageKnowledgeBases")}
+ onFooterClick={() => {
+ onNavigateAway?.();
+ navigate("/knowledge-bases");
+ }}
+ renderItem={(knowledgeBase) => {
+ const active = selectedKnowledgeBaseIds.includes(knowledgeBase.id);
+ return (
+
+
+ {knowledgeIconForName(knowledgeBase.icon_name, 16)}
+
+
+
+ {knowledgeBase.name}
+
+ {knowledgeBase.default_open &&
+ currentUserId != null &&
+ knowledgeBase.owner_user_id === currentUserId ? (
+
+ {t("knowledgeBases.defaultOpenBadge")}
+
+ ) : knowledgeBase.description ? (
+
+ {knowledgeBase.description}
+
+ ) : null}
+
+ {
+ const next = checked
+ ? [...selectedKnowledgeBaseIds, knowledgeBase.id]
+ : selectedKnowledgeBaseIds.filter(
+ (id) => id !== knowledgeBase.id,
+ );
+ onKnowledgeBaseIdsChange(next);
+ }}
+ />
+
+ );
+ }}
+ />
+ );
+}
diff --git a/dashboard/src/pages/Chat/components/MessageBubble.tsx b/dashboard/src/pages/Chat/components/MessageBubble.tsx
index 0821caa1..2451e605 100644
--- a/dashboard/src/pages/Chat/components/MessageBubble.tsx
+++ b/dashboard/src/pages/Chat/components/MessageBubble.tsx
@@ -11,6 +11,7 @@ import {
Pencil,
Volume2,
Settings,
+ GitBranch,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -53,6 +54,9 @@ interface MessageBubbleProps {
composerLookups?: ComposerTagLookups;
onRegenerate?: (messageId: string) => void;
onEditUserMessage?: (messageId: string, newText: string) => void;
+ onForkUserMessage?: (messageId: string) => void;
+ forkDisabled?: boolean;
+ forkDisabledHint?: string;
onHitlDecision?: (
decisions: Array<{ type: string; message?: string }>,
) => void;
@@ -493,6 +497,9 @@ function MessageBubble({
composerLookups,
onRegenerate,
onEditUserMessage,
+ onForkUserMessage,
+ forkDisabled,
+ forkDisabledHint,
onHitlDecision,
compact,
groupPosition = "only",
@@ -724,7 +731,9 @@ function MessageBubble({
)}
{message.content && {message.content}
}
- {(message.content || onEditUserMessage) && (
+ {(message.content ||
+ onEditUserMessage ||
+ onForkUserMessage) && (
{message.content ? (
@@ -743,6 +752,22 @@ function MessageBubble({
) : null}
+ {onForkUserMessage ? (
+
onForkUserMessage(message.id)}
+ disabled={forkDisabled}
+ title={
+ forkDisabled && forkDisabledHint
+ ? forkDisabledHint
+ : t("chat.forkFromHere")
+ }
+ type="button"
+ aria-label={t("chat.forkFromHere")}
+ >
+
+
+ ) : null}
)}
diff --git a/dashboard/src/pages/Chat/components/MessageList.tsx b/dashboard/src/pages/Chat/components/MessageList.tsx
index fd1f1d48..8146dade 100644
--- a/dashboard/src/pages/Chat/components/MessageList.tsx
+++ b/dashboard/src/pages/Chat/components/MessageList.tsx
@@ -96,6 +96,9 @@ interface MessageListProps {
onCancel?: () => void;
onRegenerate?: (messageId: string) => void;
onEditUserMessage?: (messageId: string, newText: string) => void;
+ onForkUserMessage?: (messageId: string) => void;
+ forkDisabled?: boolean;
+ forkDisabledHint?: string;
onAcpPermissionSelect?: (message: string) => void;
onHitlDecision?: (
decisions: Array<{ type: string; message?: string }>,
@@ -117,6 +120,9 @@ interface GroupRenderContext {
lastUserGroupIndex: number;
onRegenerate?: (messageId: string) => void;
onEditUserMessage?: (messageId: string, newText: string) => void;
+ onForkUserMessage?: (messageId: string) => void;
+ forkDisabled?: boolean;
+ forkDisabledHint?: string;
onAcpPermissionSelect?: (message: string) => void;
onHitlDecision?: (
decisions: Array<{ type: string; message?: string }>,
@@ -185,6 +191,9 @@ function renderMessageGroup(
composerLookups={ctx.composerLookups}
onRegenerate={ctx.onRegenerate}
onEditUserMessage={ctx.onEditUserMessage}
+ onForkUserMessage={ctx.onForkUserMessage}
+ forkDisabled={ctx.forkDisabled}
+ forkDisabledHint={ctx.forkDisabledHint}
/>
);
@@ -236,6 +245,9 @@ export default function MessageList(props: MessageListProps) {
onCancel,
onRegenerate,
onEditUserMessage,
+ onForkUserMessage,
+ forkDisabled,
+ forkDisabledHint,
onAcpPermissionSelect,
onHitlDecision,
onOpenBrowser,
@@ -661,6 +673,9 @@ export default function MessageList(props: MessageListProps) {
lastUserGroupIndex,
onRegenerate,
onEditUserMessage,
+ onForkUserMessage,
+ forkDisabled,
+ forkDisabledHint,
onAcpPermissionSelect,
onHitlDecision,
onOpenBrowser,
@@ -680,6 +695,9 @@ export default function MessageList(props: MessageListProps) {
lastUserGroupIndex,
onRegenerate,
onEditUserMessage,
+ onForkUserMessage,
+ forkDisabled,
+ forkDisabledHint,
onAcpPermissionSelect,
onHitlDecision,
onOpenBrowser,
diff --git a/dashboard/src/pages/Chat/components/SessionList.tsx b/dashboard/src/pages/Chat/components/SessionList.tsx
index b3d94b6a..8b2be3ec 100644
--- a/dashboard/src/pages/Chat/components/SessionList.tsx
+++ b/dashboard/src/pages/Chat/components/SessionList.tsx
@@ -1,7 +1,7 @@
import { memo, useCallback, useMemo, useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
-import { Dropdown } from "antd";
+import { Dropdown, Tooltip } from "antd";
import type { MenuProps } from "antd";
import {
Pencil,
@@ -10,10 +10,12 @@ import {
Pin,
PinOff,
Search,
+ Users,
} from "lucide-react";
import type { Session } from "../hooks/useSessions";
import type { OctopAgent } from "../../../context/AgentContext";
import { isAgentChatReady } from "../../../utils/agentError";
+import { isSharedExpertViewer } from "../../../utils/sharedExpert";
import { showConfirmModal } from "../../../utils/confirmModal";
import { iconForName } from "../../Experts/components/iconForName";
import SessionChannelIcon from "./SessionChannelIcon";
@@ -32,6 +34,25 @@ function AgentUnreadBadge({ count }: { count: number }) {
);
}
+function SharedExpertHint({ agent }: { agent: OctopAgent }) {
+ const { t } = useTranslation();
+ if (!isSharedExpertViewer(agent)) return null;
+ const tip = t("chat.sharedExpert.banner", {
+ name: agent.owner_username || "—",
+ });
+ return (
+
+ event.stopPropagation()}
+ >
+
+
+
+ );
+}
+
interface SessionItemProps {
session: Session;
isActive: boolean;
@@ -257,6 +278,7 @@ function ActiveAgentCard({
{agent.description ? (
@@ -333,6 +355,7 @@ function InactiveAgentRow({ agent, onSelect }: AgentRowProps) {
{agent.description || "—"}
diff --git a/dashboard/src/pages/Chat/components/UserMessageComposerTags.tsx b/dashboard/src/pages/Chat/components/UserMessageComposerTags.tsx
index bfbaf2f6..782420fb 100644
--- a/dashboard/src/pages/Chat/components/UserMessageComposerTags.tsx
+++ b/dashboard/src/pages/Chat/components/UserMessageComposerTags.tsx
@@ -3,9 +3,11 @@ import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { UserComposerContext } from "../hooks/useChat";
import type { SkillSpec } from "../../Agent/Skills/useSkills";
+import type { KnowledgeBase } from "../../../api/modules/knowledgeBases";
import type { ChatAgentOption } from "./ExpertAgentAvatar";
import ExpertAgentAvatar from "./ExpertAgentAvatar";
import { ConnectorLogo } from "../../Agent/Connectors/connectorDefs";
+import { knowledgeIconForName } from "../../KnowledgeBases/knowledgeIcons";
import ContextChip, { type ContextChipVariant } from "./ContextChip";
import { skillChipLabel } from "../utils/skillChipLabel";
import { modelShortLabel } from "../../../utils/modelOptions";
@@ -14,6 +16,7 @@ import styles from "../index.module.less";
export interface ComposerTagLookups {
skills?: SkillSpec[];
connectors?: { mcp_server_name: string; label: string; kind: string }[];
+ knowledgeBases?: KnowledgeBase[];
agents?: ChatAgentOption[];
}
@@ -64,6 +67,18 @@ export default function UserMessageComposerTags({
});
}
+ for (const id of context.knowledgeBaseIds ?? []) {
+ const knowledgeBase = lookups?.knowledgeBases?.find(
+ (base) => base.id === id,
+ );
+ items.push({
+ key: `knowledge-${id}`,
+ variant: "knowledge",
+ icon: knowledgeIconForName(knowledgeBase?.icon_name, 11),
+ label: knowledgeBase?.name || id,
+ });
+ }
+
for (const id of context.targetAgents ?? []) {
const agent = lookups?.agents?.find((a) => a.agent_id === id);
items.push({
diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts
index cffd0074..522e5600 100644
--- a/dashboard/src/pages/Chat/hooks/chatStore.ts
+++ b/dashboard/src/pages/Chat/hooks/chatStore.ts
@@ -123,6 +123,24 @@ export function consumePendingPrefillText(): string {
return val;
}
+let _pendingPrefillAttachments: ChatAttachment[] = [];
+
+/** Enqueue attachments to restore in the composer on the next Chat mount / thread switch. */
+export function setPendingPrefillAttachments(
+ attachments: ChatAttachment[],
+): void {
+ _pendingPrefillAttachments = attachments.map((attachment) => ({
+ ...attachment,
+ }));
+}
+
+/** Consume pending prefill attachments (clears after reading). */
+export function consumePendingPrefillAttachments(): ChatAttachment[] {
+ const val = _pendingPrefillAttachments;
+ _pendingPrefillAttachments = [];
+ return val.map((attachment) => ({ ...attachment }));
+}
+
// ── Composer draft (sessionStorage) ───────────────────────────────────────
// Survives navigating away from /chat and back; keyed per agent + thread.
@@ -1662,6 +1680,7 @@ async function sendTurnWebSocket(
modelRef?: string | null,
threadId?: string | null,
mcpServers?: string[] | null,
+ knowledgeBaseIds?: string[] | null,
skills?: string[] | null,
targetAgentIds?: string[] | null,
onStreamEnd?: () => void,
@@ -1734,6 +1753,11 @@ async function sendTurnWebSocket(
if (mcpServers !== undefined && mcpServers !== null) {
payload.mcp_servers = mcpServers;
}
+ // Always send the array (including []) so the server can honor Dashboard
+ // opt-out of default_open knowledge bases for this turn.
+ if (knowledgeBaseIds !== undefined && knowledgeBaseIds !== null) {
+ payload.knowledge_base_ids = knowledgeBaseIds;
+ }
if (skills && skills.length > 0) payload.skills = skills;
if (targetAgentIds && targetAgentIds.length > 0) {
payload.target_agent_ids = targetAgentIds;
@@ -1859,6 +1883,7 @@ export async function sendTurn(
modelRef?: string | null,
threadId?: string | null,
mcpServers?: string[] | null,
+ knowledgeBaseIds?: string[] | null,
skills?: string[] | null,
targetAgentIds?: string[] | null,
reasoningMode?: "auto" | "enabled" | "disabled",
@@ -1917,6 +1942,7 @@ export async function sendTurn(
modelRef,
threadId,
mcpServers,
+ knowledgeBaseIds,
skills,
targetAgentIds,
onStreamEnd,
diff --git a/dashboard/src/pages/Chat/hooks/sseHelpers.ts b/dashboard/src/pages/Chat/hooks/sseHelpers.ts
index fe8ff98f..00351d2f 100644
--- a/dashboard/src/pages/Chat/hooks/sseHelpers.ts
+++ b/dashboard/src/pages/Chat/hooks/sseHelpers.ts
@@ -42,10 +42,11 @@ export interface ChatAttachment {
kind: "image" | "file";
}
-/** Skills, connectors, experts, and selected model attached at send time. */
+/** Skills, connectors, knowledge bases, experts, and selected model attached at send time. */
export interface UserComposerContext {
skills?: string[];
connectors?: string[];
+ knowledgeBaseIds?: string[];
targetAgents?: string[];
model?: string;
reasoningMode?: "auto" | "enabled" | "disabled";
diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts
index 4f103be3..50d60181 100644
--- a/dashboard/src/pages/Chat/hooks/useChat.ts
+++ b/dashboard/src/pages/Chat/hooks/useChat.ts
@@ -645,6 +645,7 @@ export function useChat(
storeKey?: string,
modelRef?: string | null,
mcpServers?: string[] | null,
+ knowledgeBaseIds?: string[] | null,
skills?: string[] | null,
targetAgentIds?: string[] | null,
composerContext?: UserComposerContext,
@@ -678,6 +679,7 @@ export function useChat(
modelRef,
threadIdForApi,
mcpServers,
+ knowledgeBaseIds,
skills,
targetAgentIds,
reasoningMode,
diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts
index fb04d1e7..4a7fec9b 100644
--- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts
+++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts
@@ -4,9 +4,14 @@ import { providerApi } from "../../../api/modules/provider";
import { preferencesApi } from "../../../api/modules/preferences";
import { octopThreadsApi } from "../../../api/modules/octopThreads";
import { request } from "../../../api/request";
+import {
+ knowledgeBasesApi,
+ type KnowledgeBase,
+} from "../../../api/modules/knowledgeBases";
import type { ResolvedModel } from "../../../api/types";
import type { SkillSpec } from "../../Agent/Skills/useSkills";
import { CONNECTORS_CHANGED_EVENT } from "../../Agent/Connectors/customMcpUtils";
+import { useCurrentUser } from "../../../hooks/useCurrentUser";
import { activeModelToRef } from "./useChatContextWindow";
import {
hasSavedConnectors,
@@ -16,6 +21,7 @@ import {
saveSkills,
} from "../utils/chatStorage";
import { resolveInitialConnectors } from "../utils/resolveInitialConnectors";
+import { withDefaultOpenKnowledgeBases } from "../utils/withDefaultOpenKnowledgeBases";
export function useChatComposerResources(
resolvedAgentId: string | null | undefined,
@@ -25,8 +31,16 @@ export function useChatComposerResources(
stickyReasoningMode?: "auto" | "enabled" | "disabled" | null,
stickyReasoningEffort?: string | null,
) {
+ const user = useCurrentUser();
+ const currentUserId = user?.id ?? null;
const [selectedConnectors, setSelectedConnectors] = useState
([]);
const [selectedSkills, setSelectedSkills] = useState([]);
+ const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<
+ string[]
+ >([]);
+ const [chatKnowledgeBases, setChatKnowledgeBases] = useState<
+ KnowledgeBase[] | undefined
+ >(undefined);
const [chatConnectors, setChatConnectors] = useState<
{
mcp_server_name: string;
@@ -167,6 +181,38 @@ export function useChatComposerResources(
});
}, [resolvedAgentId, chatSkills]);
+ useEffect(() => {
+ let cancelled = false;
+ setSelectedKnowledgeBaseIds([]);
+ setChatKnowledgeBases(undefined);
+ void knowledgeBasesApi
+ .getCapability()
+ .then((capability) => {
+ if (cancelled || !capability.usable) return;
+ return knowledgeBasesApi.list().then((bases) => {
+ if (cancelled) return;
+ setChatKnowledgeBases(bases);
+ const ownedDefaults = bases
+ .filter(
+ (base) =>
+ base.default_open &&
+ currentUserId != null &&
+ base.owner_user_id === currentUserId,
+ )
+ .map((base) => base.id);
+ setSelectedKnowledgeBaseIds((previous) =>
+ withDefaultOpenKnowledgeBases(previous, ownedDefaults),
+ );
+ });
+ })
+ .catch(() => {
+ if (!cancelled) setChatKnowledgeBases(undefined);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [resolvedAgentId, currentUserId]);
+
useEffect(() => {
let cancelled = false;
const loadModels = () => {
@@ -241,6 +287,10 @@ export function useChatComposerResources(
[resolvedAgentId],
);
+ const handleKnowledgeBaseIdsChange = useCallback((ids: string[]) => {
+ setSelectedKnowledgeBaseIds(ids);
+ }, []);
+
const handleModelChange = useCallback(
(model: string | null) => {
setSelectedModel(model);
@@ -305,10 +355,13 @@ export function useChatComposerResources(
handleReasoningChange,
selectedConnectors,
selectedSkills,
+ selectedKnowledgeBaseIds,
chatConnectors,
+ chatKnowledgeBases,
availableModels,
activeModelRef,
handleConnectorsChange,
handleSkillsChange,
+ handleKnowledgeBaseIdsChange,
};
}
diff --git a/dashboard/src/pages/Chat/hooks/useChatSend.ts b/dashboard/src/pages/Chat/hooks/useChatSend.ts
index 9f5a5a7f..b16ed378 100644
--- a/dashboard/src/pages/Chat/hooks/useChatSend.ts
+++ b/dashboard/src/pages/Chat/hooks/useChatSend.ts
@@ -21,6 +21,7 @@ interface UseChatSendParams {
messagesLength: number;
selectedModel: string | null;
selectedConnectors: string[];
+ selectedKnowledgeBaseIds: string[];
selectedSkills: string[];
selectedTargetAgents: string[];
reasoningMode: "auto" | "enabled" | "disabled";
@@ -34,6 +35,7 @@ interface UseChatSendParams {
storeKey?: string,
modelRef?: string | null,
mcpServers?: string[] | null,
+ knowledgeBaseIds?: string[] | null,
skills?: string[] | null,
targetAgentIds?: string[] | null,
composerContext?: UserComposerContext,
@@ -55,6 +57,7 @@ function deriveThreadTitle(msg: string): string {
export type ChatSendOverrides = {
selectedModel?: string | null;
selectedConnectors?: string[];
+ selectedKnowledgeBaseIds?: string[];
selectedSkills?: string[];
selectedTargetAgents?: string[];
composerContext?: UserComposerContext;
@@ -72,6 +75,7 @@ export function useChatSend({
messagesLength,
selectedModel,
selectedConnectors,
+ selectedKnowledgeBaseIds,
selectedSkills,
selectedTargetAgents,
reasoningMode,
@@ -110,6 +114,8 @@ export function useChatSend({
const skills = overrides?.selectedSkills ?? selectedSkills;
const connectors = overrides?.selectedConnectors ?? selectedConnectors;
+ const knowledgeBaseIds =
+ overrides?.selectedKnowledgeBaseIds ?? selectedKnowledgeBaseIds;
const targetAgents =
overrides?.selectedTargetAgents ?? selectedTargetAgents;
const modelSelection =
@@ -122,6 +128,7 @@ export function useChatSend({
buildComposerContext({
skills,
connectors,
+ knowledgeBaseIds,
targetAgents,
selectedModel: modelSelection,
reasoningMode:
@@ -145,6 +152,7 @@ export function useChatSend({
tid,
modelOverride,
connectors,
+ knowledgeBaseIds,
skills,
targetAgents,
composerContext,
@@ -198,6 +206,7 @@ export function useChatSend({
modelOverride,
tid,
connectors,
+ knowledgeBaseIds,
skills,
targetAgents,
composerContext?.reasoningMode ?? reasoningMode,
@@ -218,6 +227,7 @@ export function useChatSend({
resolvedAgentId,
selectedModel,
selectedConnectors,
+ selectedKnowledgeBaseIds,
selectedSkills,
selectedTargetAgents,
reasoningMode,
diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx
index 2c5ad59c..36b622f1 100644
--- a/dashboard/src/pages/Chat/index.tsx
+++ b/dashboard/src/pages/Chat/index.tsx
@@ -13,11 +13,12 @@ import { Tooltip } from "antd";
import { message as antMessage } from "@/utils/antdMessage";
import { useIsMobile } from "../../hooks/useIsMobile";
-import { useUserRole } from "../../hooks/useUserRole";
+import { useCurrentUser } from "../../hooks/useCurrentUser";
+import { userCan } from "../../utils/permissions";
import { useChat } from "./hooks/useChat";
import { useSessions } from "./hooks/useSessions";
import * as chatStore from "./hooks/chatStore";
-import { formatRunUsage } from "./utils/chatMessages";
+import { formatRunUsage, userTurnsFromEnd } from "./utils/chatMessages";
import { useChatSidebarState } from "./hooks/useChatSidebarState";
import { useChatHistoryRail } from "./hooks/useChatHistoryRail";
import { useChatDockPanel } from "./hooks/useChatDockPanel";
@@ -37,6 +38,7 @@ import { useChatFileDetection } from "./hooks/useChatFileDetection";
import { useSkillRecordingWorkflow } from "./hooks/useSkillRecordingWorkflow";
import { dedupeDockFilePaths } from "./utils/dockFilePath";
import { browserApi } from "../../api/modules/browser";
+import { octopThreadsApi } from "../../api/modules/octopThreads";
import type { TokenUsage } from "../../api/types";
import type { ChatAttachment } from "./hooks/useChat";
import MessageList from "./components/MessageList";
@@ -49,12 +51,14 @@ import { useSkills } from "../Agent/Skills/useSkills";
import { useAgent } from "../../context/AgentContext";
import { useBrowserSessionState } from "../../hooks/useBrowserSessionState";
import { prefetchVoiceConfig } from "../../hooks/useVoiceConfig";
+import { isSharedExpertViewer } from "../../utils/sharedExpert";
import ChatDockPanels from "./components/ChatDockPanels";
import { ChatFilePreviewProvider } from "./ChatFilePreviewContext";
import ChatSidebarPanel from "./components/ChatSidebarPanel";
import ChatTitleBar from "./components/ChatTitleBar";
import ChatComposerChrome from "./components/ChatComposerChrome";
import { isAgentChatReady } from "../../utils/agentError";
+import { apiErrorMessage } from "../../utils/apiError";
import PwaInstallPrompt from "../../components/PwaInstallPrompt";
import { promptNeedsUserInput } from "../../utils/quickInputPrefill";
import styles from "./index.module.less";
@@ -73,8 +77,8 @@ function ChatPageInner() {
threadId?: string;
}>();
const isMobile = useIsMobile();
- const role = useUserRole();
- const isAdmin = role === "admin";
+ const user = useCurrentUser();
+ const canTerminal = userCan(user, "terminal");
const chatHistoryRail = useChatHistoryRail();
const [selectedTargetAgents, setSelectedTargetAgents] = useState(
[],
@@ -147,6 +151,7 @@ function ChatPageInner() {
[agents, resolvedAgentId],
);
const agentChatReady = isAgentChatReady(activeAgent?.state);
+ const sharedExpertViewer = isSharedExpertViewer(activeAgent ?? {});
const noAgents = !agentsLoading && agents.length === 0;
useEffect(() => {
@@ -156,7 +161,9 @@ function ChatPageInner() {
const { quickCards: expertQuickCards, welcomeSuffix } =
useExpertChatWelcome(activeAgent);
const { skills: chatSkills } = useSkills(
- agentChatReady && !agentsLoading ? resolvedAgentId ?? null : null,
+ agentChatReady && !agentsLoading && !sharedExpertViewer
+ ? resolvedAgentId ?? null
+ : null,
);
const [agentProfileOpen, setAgentProfileOpen] = useState(false);
@@ -289,7 +296,9 @@ function ChatPageInner() {
setSelectedModel,
selectedConnectors,
selectedSkills,
+ selectedKnowledgeBaseIds,
chatConnectors,
+ chatKnowledgeBases,
availableModels,
activeModelRef,
reasoningMode,
@@ -297,6 +306,7 @@ function ChatPageInner() {
handleReasoningChange,
handleConnectorsChange,
handleSkillsChange,
+ handleKnowledgeBaseIdsChange,
} = useChatComposerResources(
resolvedAgentId,
chatSkills,
@@ -364,6 +374,9 @@ function ChatPageInner() {
name: a.name,
icon_name: a.icon_name,
color: a.color,
+ is_shared: a.is_shared,
+ is_owner: a.is_owner,
+ owner_username: a.owner_username,
})),
[agents],
);
@@ -372,9 +385,10 @@ function ChatPageInner() {
() => ({
skills: chatSkills,
connectors: chatConnectors,
+ knowledgeBases: chatKnowledgeBases,
agents: chatAgentOptions,
}),
- [chatSkills, chatConnectors, chatAgentOptions],
+ [chatSkills, chatConnectors, chatKnowledgeBases, chatAgentOptions],
);
const { handleSend } = useChatSend({
@@ -384,6 +398,7 @@ function ChatPageInner() {
messagesLength: messages.length,
selectedModel,
selectedConnectors,
+ selectedKnowledgeBaseIds,
selectedSkills,
selectedTargetAgents,
reasoningMode,
@@ -442,6 +457,7 @@ function ChatPageInner() {
selectedModel: item.composerContext?.model ?? item.modelRef ?? null,
selectedSkills: item.composerContext?.skills,
selectedConnectors: item.composerContext?.connectors,
+ selectedKnowledgeBaseIds: item.composerContext?.knowledgeBaseIds,
selectedTargetAgents: item.composerContext?.targetAgents,
threadId: ctx.threadId,
agentId: ctx.agentId || undefined,
@@ -575,6 +591,87 @@ function ChatPageInner() {
[activeThreadId, editAndResend, resolvedAgentId],
);
+ const [forking, setForking] = useState(false);
+ const hasPendingHitl = useMemo(
+ () => messages.some((message) => message.hitlData?.status === "pending"),
+ [messages],
+ );
+ const forkDisabled = forking || isStreaming || hasPendingHitl;
+ const forkDisabledHint =
+ !forking && (isStreaming || hasPendingHitl)
+ ? t("chat.forkDisabledWhileBusy")
+ : undefined;
+ const handleForkUserMessage = useCallback(
+ async (messageId: string) => {
+ const agent = resolvedAgentId;
+ if (!agent || !activeThreadId || forkDisabled) return;
+ const idx = messages.findIndex((message) => message.id === messageId);
+ if (idx < 0) return;
+ const userMsg = messages[idx];
+ if (userMsg.role !== "user") return;
+ const turnsFromEnd = userTurnsFromEnd(messages, messageId);
+ if (turnsFromEnd < 1) return;
+ setForking(true);
+ try {
+ const created = await octopThreadsApi.fork(agent, activeThreadId, {
+ message_id: messageId,
+ content: userMsg.content,
+ user_turns_from_end: turnsFromEnd,
+ });
+ const ctx = userMsg.composerContext;
+ if (ctx?.skills) handleSkillsChange(ctx.skills);
+ if (ctx?.connectors) handleConnectorsChange(ctx.connectors);
+ if (ctx?.knowledgeBaseIds) {
+ handleKnowledgeBaseIdsChange(ctx.knowledgeBaseIds);
+ }
+ if (ctx?.targetAgents) setSelectedTargetAgents(ctx.targetAgents);
+ if (ctx?.model) setSelectedModel(ctx.model);
+ if (ctx?.reasoningMode) {
+ handleReasoningChange(ctx.reasoningMode, ctx.reasoningEffort ?? null);
+ }
+ const forkAttachments = userMsg.attachments?.map((attachment) => ({
+ ...attachment,
+ }));
+ prefillInputRef.current = userMsg.content;
+ if (forkAttachments && forkAttachments.length > 0) {
+ chatStore.setPendingPrefillAttachments(forkAttachments);
+ }
+ chatInputRef.current?.setPrefillComposer(
+ userMsg.content,
+ forkAttachments,
+ );
+ await ensureThreadInList(created.thread_id);
+ navigate(`/chat/${agent}/${created.thread_id}`, {
+ state: { prefillInput: userMsg.content },
+ });
+ antMessage.success(
+ created.copied_messages > 0
+ ? t("chat.forkSuccess")
+ : t("chat.forkSuccessEmpty"),
+ );
+ } catch (error) {
+ antMessage.error(apiErrorMessage(error, t("chat.forkFailed"), t));
+ } finally {
+ setForking(false);
+ }
+ },
+ [
+ activeThreadId,
+ forkDisabled,
+ handleConnectorsChange,
+ handleKnowledgeBaseIdsChange,
+ handleReasoningChange,
+ handleSkillsChange,
+ messages,
+ navigate,
+ resolvedAgentId,
+ setSelectedModel,
+ setSelectedTargetAgents,
+ t,
+ ensureThreadInList,
+ ],
+ );
+
const hasMessages = messages.length > 0;
// On hard refresh / deep-link into a thread, messages start empty. Showing
// Welcome until history returns looks like a full page flash. Keep the list
@@ -662,7 +759,7 @@ function ChatPageInner() {
{activeSessionTitle}
)}
- {resolvedAgentId && (
+ {resolvedAgentId && !sharedExpertViewer && (
0 && !isMobile
+ !sharedExpertViewer && panelFilePaths.length > 0 && !isMobile
? openFileList
: undefined
}
@@ -736,7 +836,7 @@ function ChatPageInner() {
{/* PWA install first when available — same column as browser / experts. */}
- {resolvedAgentId && (
+ {resolvedAgentId && !sharedExpertViewer && (
)}
- {panelFilePaths.length > 0 && (
+ {!sharedExpertViewer && panelFilePaths.length > 0 && (
)}
- {isAdmin && (
+ {canTerminal && (
-
setAgentProfileOpen(false)}
- />
+ {!sharedExpertViewer && (
+ setAgentProfileOpen(false)}
+ />
+ )}
);
diff --git a/dashboard/src/pages/Chat/utils/chatMessages.test.ts b/dashboard/src/pages/Chat/utils/chatMessages.test.ts
index bdee9f87..ad4d43f4 100644
--- a/dashboard/src/pages/Chat/utils/chatMessages.test.ts
+++ b/dashboard/src/pages/Chat/utils/chatMessages.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { resolveTurnModelOverride, resolveTurnModelRef } from "./chatMessages";
+import {
+ resolveTurnModelOverride,
+ resolveTurnModelRef,
+ userTurnsFromEnd,
+} from "./chatMessages";
describe("resolveTurnModelRef", () => {
it("sends only an explicit composer selection", () => {
@@ -12,8 +16,18 @@ describe("resolveTurnModelRef", () => {
});
});
-describe("resolveTurnModelOverride", () => {
- it("treats matching expert default as no override chip", () => {
- expect(resolveTurnModelOverride("p/default", "p/default")).toBeNull();
+describe("userTurnsFromEnd", () => {
+ it("counts user turns from the selected message through the latest", () => {
+ const messages = [
+ { id: "u1", role: "user" },
+ { id: "a1", role: "assistant" },
+ { id: "u2", role: "user" },
+ { id: "a2", role: "assistant" },
+ { id: "u3", role: "user" },
+ ];
+ expect(userTurnsFromEnd(messages, "u2")).toBe(2);
+ expect(userTurnsFromEnd(messages, "u3")).toBe(1);
+ expect(userTurnsFromEnd(messages, "u1")).toBe(3);
+ expect(userTurnsFromEnd(messages, "missing")).toBe(0);
});
});
diff --git a/dashboard/src/pages/Chat/utils/chatMessages.ts b/dashboard/src/pages/Chat/utils/chatMessages.ts
index 2e26ec63..14ca9f89 100644
--- a/dashboard/src/pages/Chat/utils/chatMessages.ts
+++ b/dashboard/src/pages/Chat/utils/chatMessages.ts
@@ -24,6 +24,10 @@ export function normalizeComposerContext(
ctx.connectors = raw.connectors.map((s) => String(s));
has = true;
}
+ if (Array.isArray(raw.knowledgeBaseIds) && raw.knowledgeBaseIds.length > 0) {
+ ctx.knowledgeBaseIds = raw.knowledgeBaseIds.map((id) => String(id));
+ has = true;
+ }
if (Array.isArray(raw.targetAgents) && raw.targetAgents.length > 0) {
ctx.targetAgents = raw.targetAgents.map((s) => String(s));
has = true;
@@ -79,6 +83,7 @@ export function resolveTurnModelOverride(
export function buildComposerContext(params: {
skills?: string[];
connectors?: string[];
+ knowledgeBaseIds?: string[];
targetAgents?: string[];
selectedModel?: string | null;
reasoningMode?: "auto" | "enabled" | "disabled";
@@ -95,6 +100,10 @@ export function buildComposerContext(params: {
ctx.connectors = [...params.connectors];
has = true;
}
+ if (params.knowledgeBaseIds && params.knowledgeBaseIds.length > 0) {
+ ctx.knowledgeBaseIds = [...params.knowledgeBaseIds];
+ has = true;
+ }
if (params.targetAgents && params.targetAgents.length > 0) {
ctx.targetAgents = [...params.targetAgents];
has = true;
@@ -136,6 +145,17 @@ export function formatRunUsage(
return parts.length > 0 ? parts.join(" / ") : null;
}
+/** Count user turns from *messageId* through the latest (inclusive). */
+export function userTurnsFromEnd(
+ messages: Array<{ id: string; role: string }>,
+ messageId: string,
+): number {
+ const idx = messages.findIndex((message) => message.id === messageId);
+ if (idx < 0) return 0;
+ return messages.slice(idx).filter((message) => message.role === "user")
+ .length;
+}
+
export function buildUserMessage(
text: string,
attachments?: ChatAttachment[],
diff --git a/dashboard/src/pages/Chat/utils/withDefaultOpenKnowledgeBases.test.ts b/dashboard/src/pages/Chat/utils/withDefaultOpenKnowledgeBases.test.ts
new file mode 100644
index 00000000..7e8394aa
--- /dev/null
+++ b/dashboard/src/pages/Chat/utils/withDefaultOpenKnowledgeBases.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+
+import { withDefaultOpenKnowledgeBases } from "./withDefaultOpenKnowledgeBases";
+
+describe("withDefaultOpenKnowledgeBases", () => {
+ it("adds default_open knowledge bases to the selection", () => {
+ expect(withDefaultOpenKnowledgeBases(["kb-b"], ["kb-a", "kb-c"])).toEqual([
+ "kb-b",
+ "kb-a",
+ "kb-c",
+ ]);
+ });
+
+ it("does not duplicate selected defaults", () => {
+ expect(withDefaultOpenKnowledgeBases(["kb-a", "kb-b"], ["kb-a"])).toEqual([
+ "kb-a",
+ "kb-b",
+ ]);
+ });
+});
diff --git a/dashboard/src/pages/Chat/utils/withDefaultOpenKnowledgeBases.ts b/dashboard/src/pages/Chat/utils/withDefaultOpenKnowledgeBases.ts
new file mode 100644
index 00000000..dffa5054
--- /dev/null
+++ b/dashboard/src/pages/Chat/utils/withDefaultOpenKnowledgeBases.ts
@@ -0,0 +1,11 @@
+/** Always keep default_open knowledge bases in the composer selection. */
+export function withDefaultOpenKnowledgeBases(
+ selected: string[],
+ defaults: string[],
+): string[] {
+ const next = [...selected];
+ for (const id of defaults) {
+ if (!next.includes(id)) next.push(id);
+ }
+ return next;
+}
diff --git a/dashboard/src/pages/Control/CronJobs/components/CronJobCard.tsx b/dashboard/src/pages/Control/CronJobs/components/CronJobCard.tsx
index 81ff116d..4628613e 100644
--- a/dashboard/src/pages/Control/CronJobs/components/CronJobCard.tsx
+++ b/dashboard/src/pages/Control/CronJobs/components/CronJobCard.tsx
@@ -14,7 +14,6 @@ import styles from "../index.module.less";
import {
CHANNEL_ICONS,
CHANNEL_LABEL_KEYS,
- getChannelColor,
} from "../../../Agent/Channels/components/constants";
import {
channelFromSessionKey,
@@ -68,18 +67,10 @@ export function CronJobCard({
typeof meta.octop_last_run_at === "number" ? meta.octop_last_run_at : null;
const accent = job.enabled ? ENABLED_ACCENT : DISABLED_ACCENT;
- const channelColor = getChannelColor(channel);
const taskType = job.task_type === "text" ? "text" : "agent";
const moreMenuItems: MenuProps["items"] = [
- {
- key: "edit",
- label: t("common.edit"),
- icon: ,
- disabled: job.enabled,
- onClick: () => onEdit(job),
- },
{
key: "delete",
label: t("common.delete"),
@@ -95,15 +86,6 @@ export function CronJobCard({
className={styles.cronCard}
style={{ "--cron-accent": accent } as React.CSSProperties}
>
- {/* Accent top bar — channel brand color */}
-
-
{/* Header */}
{/* Channel icon box */}
diff --git a/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx b/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx
index aa3f2bde..1b94bcb0 100644
--- a/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx
+++ b/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx
@@ -107,8 +107,6 @@ export function JobDrawer({
[t],
);
- const quickCronOptions = presetOptions;
-
useEffect(() => {
if (!open) return;
if (editingJob) {
@@ -274,7 +272,7 @@ export function JobDrawer({
) : (
-
-
- t("cronJobs.totalItems", { count: total }),
- }}
- />
-
+
+ t("cronJobs.totalItems", { count: total }),
+ }}
+ />
)}
)}
diff --git a/dashboard/src/pages/Control/RemoteBrowser/index.tsx b/dashboard/src/pages/Control/RemoteBrowser/index.tsx
index 47d3da53..ee815c39 100644
--- a/dashboard/src/pages/Control/RemoteBrowser/index.tsx
+++ b/dashboard/src/pages/Control/RemoteBrowser/index.tsx
@@ -1063,7 +1063,7 @@ export default function RemoteBrowserPage({
title={t("remoteBrowser.installFailed", "安装失败")}
subTitle={t(
"remoteBrowser.installFailedHint",
- "可重试或手动运行 playwright install chromium",
+ "自动安装失败,请重试。",
)}
style={{ padding: "8px 0" }}
/>
diff --git a/dashboard/src/pages/Control/RemoteDesktop/index.tsx b/dashboard/src/pages/Control/RemoteDesktop/index.tsx
index 6ce7b18f..a2736531 100644
--- a/dashboard/src/pages/Control/RemoteDesktop/index.tsx
+++ b/dashboard/src/pages/Control/RemoteDesktop/index.tsx
@@ -28,11 +28,11 @@ import {
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router-dom";
import StreamConnectingIndicator from "../../../components/StreamConnectingIndicator";
import StreamEdgeControls from "../../../components/StreamEdgeControls/StreamEdgeControls";
import StreamSetupGuide from "../../../components/StreamSetupGuide/StreamSetupGuide";
+import ForbiddenPage from "../../../components/ForbiddenPage";
import PageShell from "../../../layouts/PageShell";
import {
desktopApi,
@@ -56,7 +56,8 @@ import {
import { useDesktopCanvasInteraction } from "../../../hooks/useDesktopCanvasInteraction";
import { useIsMobile } from "../../../hooks/useIsMobile";
import { useLandscapeFullscreen } from "../../../hooks/useLandscapeFullscreen";
-import { useUserRole } from "../../../hooks/useUserRole";
+import { useCurrentUser } from "../../../hooks/useCurrentUser";
+import { userCan } from "../../../utils/permissions";
import { copyText } from "../../../utils/copyText";
import { showApiError } from "../../../utils/showApiToast";
import { wsStreamErrorMessage } from "../../../utils/apiError";
@@ -79,8 +80,8 @@ const DEFAULT_MAX_FPS = 10;
export default function RemoteDesktopPage() {
const { t } = useTranslation();
- const navigate = useNavigate();
- const role = useUserRole();
+ const user = useCurrentUser();
+ const canDesktop = userCan(user, "desktop");
const isMobile = useIsMobile();
const canvasRef = useRef(null);
const viewportRef = useRef(null);
@@ -188,10 +189,10 @@ export default function RemoteDesktopPage() {
}, [t]);
useEffect(() => {
- if (role === "admin") {
+ if (canDesktop) {
void refreshEnv();
}
- }, [role, refreshEnv]);
+ }, [canDesktop, refreshEnv]);
useEffect(() => {
if (installLogRef.current) {
@@ -963,7 +964,7 @@ export default function RemoteDesktopPage() {
);
- if (role === null) {
+ if (user === null) {
return (
@@ -973,28 +974,8 @@ export default function RemoteDesktopPage() {
);
}
- if (role !== "admin") {
- return (
-
-
-
-
-
- {t("remoteDesktop.adminOnlyTitle", "需要管理员权限")}
-
-
- {t(
- "remoteDesktop.adminOnlyDesc",
- "远程桌面可操控主机操作系统,仅管理员可用。如需使用,请联系管理员。",
- )}
-
-
navigate("/chat")}>
- {t("remoteDesktop.adminOnlyBack", "返回对话")}
-
-
-
-
- );
+ if (!canDesktop) {
+ return
;
}
const headerActions = (
diff --git a/dashboard/src/pages/Control/Workbench/index.tsx b/dashboard/src/pages/Control/Workbench/index.tsx
index 413a8422..0a4deaf6 100644
--- a/dashboard/src/pages/Control/Workbench/index.tsx
+++ b/dashboard/src/pages/Control/Workbench/index.tsx
@@ -1,8 +1,10 @@
-import { useMemo } from "react";
+import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Globe, TerminalSquare } from "lucide-react";
import PageShell from "../../../layouts/PageShell";
import { usePathTabs } from "../../../hooks/usePathTabs";
+import { useCurrentUser } from "../../../hooks/useCurrentUser";
+import { userCan } from "../../../utils/permissions";
import TerminalPage from "../Terminal";
import RemoteBrowserPage from "../RemoteBrowser";
import styles from "./index.module.less";
@@ -28,28 +30,39 @@ export default function WorkbenchPage({
isVisible = true,
}: WorkbenchPageProps) {
const { t } = useTranslation();
+ const user = useCurrentUser();
+ const isAllowed = useCallback(
+ (tab: WorkbenchTab) => userCan(user, tab),
+ [user],
+ );
+ const defaultTab: WorkbenchTab = userCan(user, "browser")
+ ? "browser"
+ : "terminal";
const { activeTab, handleTabChange, isMounted } = usePathTabs({
basePath: "/workbench",
tabs: WORKBENCH_TABS,
storageKey: "octop:workbench:tab",
- defaultTab: "browser",
+ defaultTab,
+ isAllowed,
});
const pathTabs = useMemo(
() => ({
value: activeTab,
onChange: handleTabChange,
- options: WORKBENCH_TABS.map((value) => {
- const Icon = TAB_ICONS[value];
- return {
- value,
- label: t(`workbench.tabs.${value}`),
- icon:
,
- };
- }),
+ options: WORKBENCH_TABS.filter((value) => isAllowed(value)).map(
+ (value) => {
+ const Icon = TAB_ICONS[value];
+ return {
+ value,
+ label: t(`workbench.tabs.${value}`),
+ icon:
,
+ };
+ },
+ ),
}),
- [activeTab, handleTabChange, t],
+ [activeTab, handleTabChange, isAllowed, t],
);
const browserVisible = isVisible && activeTab === "browser";
diff --git a/dashboard/src/pages/Experts/components/AgentCard.tsx b/dashboard/src/pages/Experts/components/AgentCard.tsx
index 5025245b..ecc5432a 100644
--- a/dashboard/src/pages/Experts/components/AgentCard.tsx
+++ b/dashboard/src/pages/Experts/components/AgentCard.tsx
@@ -2,7 +2,7 @@
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
-import { Popconfirm, Switch, Tooltip } from "antd";
+import { Popconfirm, Switch, Tag, Tooltip } from "antd";
import { message } from "@/utils/antdMessage";
import {
@@ -37,6 +37,9 @@ import {
isAgentModelConfigError,
} from "../../../utils/agentError";
import styles from "../index.module.less";
+import { isSharedExpertViewer } from "../../../utils/sharedExpert";
+import type { PublishedExpert } from "../../../api/modules/publishedExperts";
+import PublishTemplateButton from "./PublishTemplateButton";
const STATE_META: Record<
string,
@@ -60,6 +63,8 @@ export interface AgentCardProps {
agent: OctopAgent;
iconName?: string | null;
accentColor?: string | null;
+ publishedExpert?: PublishedExpert | null;
+ onPublishedChange?: () => void;
onEdit: (agentId: string) => void;
onDeleted: (agentId: string) => void;
onStateChange: (agentId: string, newState: string) => void;
@@ -71,6 +76,8 @@ export const AgentCard = memo(function AgentCard({
agent,
iconName,
accentColor,
+ publishedExpert = null,
+ onPublishedChange,
onEdit,
onDeleted,
onStateChange,
@@ -220,6 +227,8 @@ export const AgentCard = memo(function AgentCard({
const meta = getStateMeta(localState);
const friendlyError = formatAgentError(localError, t);
const chatReady = isAgentChatReady(localState);
+ const sharedViewer = isSharedExpertViewer(agent);
+ const isOwner = agent.is_owner !== false;
return (
<>
@@ -242,39 +251,60 @@ export const AgentCard = memo(function AgentCard({
{agent.name}
-
-
- onEdit(agent.agent_id)}
- aria-label={t("common.edit", "Edit")}
- >
-
-
-
-
void handleDelete()}
- okText={t("common.delete", "Delete")}
- cancelText={t("common.cancel")}
- okButtonProps={{ danger: true }}
- >
+ {agent.is_shared && (
+
+ {sharedViewer
+ ? t("experts.share.fromOwner", {
+ name: agent.owner_username,
+ })
+ : t("experts.share.badge")}
+
+ )}
+ {isOwner && (
+
onEdit(agent.agent_id)}
+ aria-label={t("common.edit", "Edit")}
>
-
+
-
-
+ void handleDelete()}
+ okText={t("common.delete", "Delete")}
+ cancelText={t("common.cancel")}
+ okButtonProps={{ danger: true }}
+ >
+
+
+
+
+
+
+ {onPublishedChange && (
+
+ )}
+
+ )}
setMbtiCatalogOpen(true)}
+ onClick={isOwner ? () => setMbtiCatalogOpen(true) : undefined}
/>
-
-
- void handleReload()}
- aria-label={t("experts.reloadAgent")}
- >
-
-
-
+ {isOwner && (
+
+
+ void handleReload()}
+ aria-label={t("experts.reloadAgent")}
+ >
+
+
+
- void handleToggle(checked)}
- className={styles.agentCard2Switch}
- />
-
+
void handleToggle(checked)}
+ className={styles.agentCard2Switch}
+ />
+
+ )}
{/* Description */}
@@ -364,68 +396,72 @@ export const AgentCard = memo(function AgentCard({
{/* Footer actions */}
-
- setWorkspaceDrawerOpen(true)}
- aria-label={t("pageShell.workspace.title")}
- >
-
-
-
+ {isOwner && (
+ <>
+
+ setWorkspaceDrawerOpen(true)}
+ aria-label={t("pageShell.workspace.title")}
+ >
+
+
+
-
- setSkillCatalogOpen(true)}
- aria-label={t("experts.skillsBtn")}
- >
-
-
-
+
+ setSkillCatalogOpen(true)}
+ aria-label={t("experts.skillsBtn")}
+ >
+
+
+
-
-
-
-
-
+
+
+
+
+
-
- setChannelCatalogOpen(true)}
- aria-label={t("experts.channelsBtn")}
- >
-
-
-
+
+ setChannelCatalogOpen(true)}
+ aria-label={t("experts.channelsBtn")}
+ >
+
+
+
-
- setMemoryCatalogOpen(true)}
- aria-label={t("experts.memoryBtn")}
- >
-
-
-
+
+ setMemoryCatalogOpen(true)}
+ aria-label={t("experts.memoryBtn")}
+ >
+
+
+
+ >
+ )}
{chatReady ? (
- ) : localState === "failed" ||
- localState === "stopped" ||
- localState === "created" ? (
+ ) : isOwner &&
+ (localState === "failed" ||
+ localState === "stopped" ||
+ localState === "created") ? (
= {
running: "success",
@@ -58,6 +61,8 @@ const TRANSIENT = new Set(["starting", "stopping"]);
interface AgentExpertsTableProps {
agents: OctopAgent[];
+ publishedByAgentId?: Record;
+ onPublishedChange?: () => void;
onEdit: (agentId: string) => void;
onDeleted: (agentId: string) => void;
onStateChange: (agentId: string, newState: string) => void;
@@ -65,6 +70,8 @@ interface AgentExpertsTableProps {
export default function AgentExpertsTable({
agents,
+ publishedByAgentId = {},
+ onPublishedChange,
onEdit,
onDeleted,
onStateChange,
@@ -269,6 +276,15 @@ export default function AgentExpertsTable({
{iconForName(row.icon_name, 16)}
{name}
+ {row.is_shared && (
+
+ {isSharedExpertViewer(row)
+ ? t("experts.share.fromOwner", {
+ name: row.owner_username,
+ })
+ : t("experts.share.badge")}
+
+ )}
),
},
@@ -348,7 +364,11 @@ export default function AgentExpertsTable({
render: (value: string | null, row) => (
openMbtiCatalog(row.agent_id)}
+ onClick={
+ row.is_owner !== false
+ ? () => openMbtiCatalog(row.agent_id)
+ : undefined
+ }
/>
),
},
@@ -362,109 +382,125 @@ export default function AgentExpertsTable({
const isTransient = TRANSIENT.has(state);
const switchChecked = state === "running" || state === "starting";
const chatReady = isAgentChatReady(state);
+ const isOwner = row.is_owner !== false;
return (
-
- void handleReload(row)}
- >
-
-
-
-
void handleToggle(row, checked)}
- />
-
- setWorkspaceAgentId(row.agent_id)}
- aria-label={t("pageShell.workspace.title")}
- >
-
-
-
-
- setSkillCatalogAgentId(row.agent_id)}
- aria-label={t("experts.skillsBtn")}
- >
-
-
-
-
- openSubagentCatalog(row.agent_id)}
- aria-label={t("experts.subagentsBtn")}
- >
-
-
-
-
- setChannelCatalogAgentId(row.agent_id)}
- aria-label={t("experts.channelsBtn")}
- >
-
-
-
-
- setMemoryCatalogAgentId(row.agent_id)}
- aria-label={t("experts.memoryBtn")}
- >
-
-
-
-
- onEdit(row.agent_id)}
- aria-label={t("common.edit", "Edit")}
- >
-
-
-
- void handleDelete(row)}
- okText={t("common.delete", "Delete")}
- cancelText={t("common.cancel")}
- okButtonProps={{ danger: true }}
- >
-
-
-
-
-
-
+ {isOwner && (
+ <>
+
+ void handleReload(row)}
+ >
+
+
+
+ void handleToggle(row, checked)}
+ />
+
+ setWorkspaceAgentId(row.agent_id)}
+ aria-label={t("pageShell.workspace.title")}
+ >
+
+
+
+
+ setSkillCatalogAgentId(row.agent_id)}
+ aria-label={t("experts.skillsBtn")}
+ >
+
+
+
+
+ openSubagentCatalog(row.agent_id)}
+ aria-label={t("experts.subagentsBtn")}
+ >
+
+
+
+
+ setChannelCatalogAgentId(row.agent_id)}
+ aria-label={t("experts.channelsBtn")}
+ >
+
+
+
+
+ setMemoryCatalogAgentId(row.agent_id)}
+ aria-label={t("experts.memoryBtn")}
+ >
+
+
+
+
+ onEdit(row.agent_id)}
+ aria-label={t("common.edit", "Edit")}
+ >
+
+
+
+ void handleDelete(row)}
+ okText={t("common.delete", "Delete")}
+ cancelText={t("common.cancel")}
+ okButtonProps={{ danger: true }}
+ >
+
+
+
+
+
+
+ {onPublishedChange && (
+
+ )}
+ >
+ )}
{chatReady ? (
- ) : state === "failed" ||
- state === "stopped" ||
- state === "created" ? (
+ ) : isOwner &&
+ (state === "failed" ||
+ state === "stopped" ||
+ state === "created") ? (
void;
onCreated: (agentId: string, agentName: string) => void;
}
+function sourceTitle(
+ source: CreateFromTemplateSource,
+ lang: "zh" | "en",
+): string {
+ if (source.kind === "builtin") {
+ return pickLocale(source.expert.label, lang) || source.expert.id;
+ }
+ if (source.kind === "published") {
+ return source.expert.name;
+ }
+ return pickLocale(source.expert.label, lang) || source.expert.slug;
+}
+
+function sourceDefaults(
+ source: CreateFromTemplateSource,
+ lang: "zh" | "en",
+): { name: string; description: string; color: string | null } {
+ if (source.kind === "builtin") {
+ return {
+ name: pickLocale(source.expert.label, lang) || source.expert.id,
+ description: pickLocale(source.expert.description, lang),
+ color: source.expert.color ?? null,
+ };
+ }
+ if (source.kind === "published") {
+ return {
+ name: source.expert.name,
+ description: source.expert.description,
+ color: source.expert.color ?? null,
+ };
+ }
+ return {
+ name: pickLocale(source.expert.label, lang) || source.expert.slug,
+ description: pickLocale(source.expert.description, lang),
+ color: source.expert.color ?? null,
+ };
+}
+
export default function CreateFromExpertDrawer({
open,
- expert,
+ source,
lang,
onClose,
onCreated,
}: CreateFromExpertDrawerProps) {
const { t } = useTranslation();
- const skillSlugDisplayName = useSkillSlugDisplayName();
const [form] = Form.useForm<
{
name: string;
@@ -76,7 +131,7 @@ export default function CreateFromExpertDrawer({
const [submitting, setSubmitting] = useState(false);
const { models, modelsLoading, backends, backendsLoading } =
- useAgentFormResources(open && !!expert);
+ useAgentFormResources(open && !!source);
const [pathMappings, setPathMappings] = useState([]);
@@ -84,26 +139,46 @@ export default function CreateFromExpertDrawer({
const [detailLoading, setDetailLoading] = useState(false);
const [skillPackages, setSkillPackages] = useState([]);
const [skillPackagesLoading, setSkillPackagesLoading] = useState(false);
+ const [colorPalette, setColorPalette] = useState("rose");
const backendChoice =
Form.useWatch("backend_choice", form) ?? DEFAULT_BACKEND;
+ const sourceKey = useMemo(() => {
+ if (!source) return "";
+ if (source.kind === "builtin") return `builtin:${source.expert.id}`;
+ if (source.kind === "published") return `published:${source.expert.id}`;
+ return `market:${source.expert.slug}`;
+ }, [source]);
+
useEffect(() => {
- if (!open || !expert) return;
+ if (!open || !source) return;
let cancelled = false;
setPathMappings([]);
+ const defaults = sourceDefaults(source, lang);
+ setColorPalette(resolveExpertPalette(defaults.color));
form.setFieldsValue({
- name: pickLocale(expert.label, lang) || expert.id,
- description: pickLocale(expert.description, lang),
+ name: defaults.name,
+ description: defaults.description,
default_model: MODEL_AUTO_VALUE,
backend_choice: DEFAULT_BACKEND,
composite_default: DEFAULT_BACKEND,
skill_package_ids: [],
});
+ if (source.kind === "market") {
+ setFileContents([]);
+ setDetailLoading(false);
+ return;
+ }
+
setDetailLoading(true);
- request(`/experts/${encodeURIComponent(expert.id)}`)
+ const detailPath =
+ source.kind === "builtin"
+ ? `/experts/${encodeURIComponent(source.expert.id)}`
+ : `/experts/published/${encodeURIComponent(source.expert.id)}`;
+ request(detailPath)
.then((data) => {
if (!cancelled) setFileContents(data.file_contents ?? []);
})
@@ -117,7 +192,7 @@ export default function CreateFromExpertDrawer({
return () => {
cancelled = true;
};
- }, [open, expert, lang, form]);
+ }, [open, source, sourceKey, lang, form]);
useEffect(() => {
if (!open) return;
@@ -140,7 +215,7 @@ export default function CreateFromExpertDrawer({
}, [open]);
const handleCreate = async () => {
- if (!expert) return;
+ if (!source) return;
const values = await form.validateFields();
if (values.backend_choice === "composite") {
const pathError = validatePathMappings(pathMappings, t);
@@ -178,22 +253,48 @@ export default function CreateFromExpertDrawer({
values.root_dir,
);
- const body = await request<{ agent_id: string; name: string }>(
- `/agents/from-expert/${encodeURIComponent(expert.id)}`,
- {
- method: "POST",
- body: JSON.stringify({
- name: values.name,
- description: values.description || undefined,
- default_model:
- defaultModelFromForm(values.default_model) ?? undefined,
- backend: backendSpec,
- skill_package_ids: values.skill_package_ids ?? [],
- ...buildAgentRuntimeRequest(values),
- }),
- },
- );
- message.success(t("experts.agentCreated", { name: body.name }));
+ const payload = {
+ name: values.name,
+ description: values.description || undefined,
+ default_model: defaultModelFromForm(values.default_model) ?? undefined,
+ backend: backendSpec,
+ skill_package_ids: values.skill_package_ids ?? [],
+ color: expertPaletteColor(colorPalette),
+ ...buildAgentRuntimeRequest(values),
+ };
+
+ let body: { agent_id: string; name: string };
+ if (source.kind === "builtin") {
+ body = await request<{ agent_id: string; name: string }>(
+ `/agents/from-expert/${encodeURIComponent(source.expert.id)}`,
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ },
+ );
+ } else if (source.kind === "published") {
+ body = await publishedExpertsApi.install(source.expert.id, payload);
+ } else {
+ const created = await expertMarketApi.install(
+ source.expert.slug,
+ payload,
+ );
+ body = { agent_id: created.agent_id, name: created.name };
+ const enrichment = created.market?.welcome_enrichment;
+ if (enrichment === "pending") {
+ message.success(
+ t("experts.marketCreateSuccessEnriching", { name: body.name }),
+ );
+ } else {
+ message.success(
+ t("experts.marketCreateSuccess", { name: body.name }),
+ );
+ }
+ }
+
+ if (source.kind !== "market") {
+ message.success(t("experts.agentCreated", { name: body.name }));
+ }
if (bwrapToast?.kind === "success") {
message.success(bwrapToast.text);
} else if (bwrapToast?.kind === "warning") {
@@ -228,11 +329,13 @@ export default function CreateFromExpertDrawer({
);
const createBlocked = submitting || hasNoModels;
- const { configFiles, skillGroups } = groupExpertFiles(fileContents);
+ const { configFiles, skillGroups, subagentFiles } =
+ groupExpertFiles(fileContents);
+ const showFilePreview = source?.kind !== "market";
- const title = expert
+ const title = source
? t("experts.createDrawerTitle", {
- name: pickLocale(expert.label, lang) || expert.id,
+ name: sourceTitle(source, lang),
})
: "";
@@ -286,6 +389,10 @@ export default function CreateFromExpertDrawer({
+
+
+
+
- {detailLoading ? (
-
-
-
- ) : (
- <>
- {configFiles.length > 0 && (
-
+ {showFilePreview &&
+ (detailLoading ? (
+
+
+
+ ) : (
+ <>
+ {configFiles.length > 0 && (
+
+
+ {t("experts.mdFilesTitle")}
+
+
+ {t("experts.mdFilesHint")}
+
+
{
+ const meta = metaForFile(f.name, t);
+ return {
+ key: f.name,
+ label: (
+
+ {meta.label}
+
+ {f.name}
+
+
+ ),
+ children: (
+
+ {f.content}
+
+ ),
+ };
+ })}
+ />
+
+ )}
+
+
- {t("experts.mdFilesTitle")}
+ {t("experts.skillFilesTitle", { count: skillGroups.length })}
- {t("experts.mdFilesHint")}
+ {t("experts.skillFilesHint")}
-
{
- const meta = metaForFile(f.name, t);
- return {
- key: f.name,
+ {skillGroups.length === 0 ? (
+
+ {t("experts.noSkillFiles")}
+
+ ) : (
+ ({
+ key: group.name,
label: (
- {meta.label}
+
+ {group.emoji} {group.displayName}
+
- {f.name}
+ skills/{group.name}/
),
children: (
-
- {f.content}
-
+ <>
+ {group.description ? (
+
+ {group.description}
+
+ ) : null}
+ {
+ const skillBasename = f.name.replace(
+ `skills/${group.name}/`,
+ "",
+ );
+ const skillMeta = metaForFile(skillBasename, t);
+ return {
+ key: f.name,
+ label: (
+
+
+ {skillMeta.label}
+
+
+ {skillBasename}
+
+
+ ),
+ children: (
+
+ {f.content}
+
+ ),
+ };
+ })}
+ />
+ >
),
- };
- })}
- />
+ }))}
+ />
+ )}
- )}
-
-
- {t("experts.skillFilesTitle", { count: skillGroups.length })}
-
-
- {t("experts.skillFilesHint")}
-
- {skillGroups.length === 0 ? (
-
+
+ {t("experts.subagentFilesTitle", {
+ count: subagentFiles.length,
+ })}
+
+
- {t("experts.noSkillFiles")}
-
- ) : (
-
({
- key: group.name,
- label: skillSlugDisplayName(group.name),
- children: (
- {
- const skillBasename = f.name.replace(
- `skills/${group.name}/`,
- "",
- );
- const skillMeta = metaForFile(skillBasename, t);
- return {
- key: f.name,
- label: (
-
-
- {skillMeta.label}
-
-
- {skillBasename}
-
-
- ),
- children: (
-
- {f.content}
-
- ),
- };
- })}
- />
- ),
- }))}
- />
- )}
-
- >
- )}
+ {t("experts.subagentTemplateHint")}
+
+ {subagentFiles.length === 0 ? (
+
+ {t("experts.noSubagentFiles")}
+
+ ) : (
+
({
+ key: subagent.slug,
+ label: (
+
+
+ {subagent.emoji} {subagent.name}
+
+
+ agents/{subagent.slug}.md
+
+
+ ),
+ children: (
+ <>
+ {subagent.description ? (
+
+ {subagent.description}
+
+ ) : null}
+
+ {subagent.file.content}
+
+ >
+ ),
+ }))}
+ />
+ )}
+
+ >
+ ))}
);
}
diff --git a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx
index c55e5f32..c1814bd3 100644
--- a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx
+++ b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx
@@ -11,12 +11,14 @@ import {
Modal,
Select,
Spin,
+ Switch,
} from "antd";
import { message } from "@/utils/antdMessage";
import { MoreHorizontal } from "lucide-react";
import { request } from "../../../api/request";
import { AgentAdvancedConfigFields } from "../../../components/AgentAdvancedConfigFields";
+import ExpertColorPicker from "../../../components/ExpertColorPicker";
import { workspaceApi } from "../../../api/modules/workspace";
import { apiErrorMessage } from "../../../utils/apiError";
import { isAgentChatReady } from "../../../utils/agentError";
@@ -28,6 +30,11 @@ import {
defaultModelFromForm,
defaultModelToForm,
} from "../../../utils/modelOptions";
+import {
+ expertPaletteColor,
+ resolveExpertPalette,
+} from "../../../utils/expertColor";
+import type { ThemePalette } from "../../../styles/themePalettes";
import { metaForFile } from "./iconForName";
import {
buildAgentRuntimeRequest,
@@ -59,6 +66,7 @@ interface AgentDetail {
name: string;
description: string | null;
default_model: string | null;
+ color?: string | null;
max_iters?: number | null;
max_input_length?: number | null;
temperature?: number | null;
@@ -94,6 +102,7 @@ function subagentFilePath(path: string): string {
interface EditFormValues {
name: string;
description: string;
+ is_shared?: boolean;
default_model: string;
backend_choice: string;
composite_default: string;
@@ -112,7 +121,12 @@ interface EditAgentDrawerProps {
onSaved: (
updated: Pick<
OctopAgent,
- "agent_id" | "name" | "description" | "default_model"
+ | "agent_id"
+ | "name"
+ | "description"
+ | "default_model"
+ | "is_shared"
+ | "color"
>,
) => void;
}
@@ -143,6 +157,9 @@ function EditAgentDrawerBody({
useAgentFormResources(true);
const [pathMappings, setPathMappings] = useState([]);
const [agentConfig, setAgentConfig] = useState>({});
+ const [colorPalette, setColorPalette] = useState(() =>
+ resolveExpertPalette(agent.color),
+ );
const [loading, setLoading] = useState(false);
const [filesLoading, setFilesLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -178,12 +195,18 @@ function EditAgentDrawerBody({
const cfg = ag.config ?? {};
setAgentConfig(cfg);
+ const colorFromCfg =
+ typeof cfg.color === "string"
+ ? cfg.color
+ : ag.color ?? agent.color ?? null;
+ setColorPalette(resolveExpertPalette(colorFromCfg));
const parsedBackend = parseBackendSpec(cfg.backend);
setPathMappings(parsedBackend.pathMappings);
form.setFieldsValue({
name: ag.name,
description: ag.description ?? "",
+ is_shared: agent.is_shared ?? false,
default_model: defaultModelToForm(ag.default_model),
backend_choice: parsedBackend.backendChoice,
composite_default: parsedBackend.compositeDefault,
@@ -278,9 +301,11 @@ function EditAgentDrawerBody({
values.root_dir,
);
+ const nextColor = expertPaletteColor(colorPalette);
const nextConfig = omitAgentRuntimeConfig({
...agentConfig,
backend: backendSpec,
+ color: nextColor,
});
await request(`/agents/${agent.agent_id}`, {
@@ -288,6 +313,7 @@ function EditAgentDrawerBody({
body: JSON.stringify({
name: values.name,
description: values.description || null,
+ is_shared: values.is_shared ?? false,
default_model: defaultModelFromForm(values.default_model),
config: nextConfig,
...buildAgentRuntimeRequest(values, { clearMissing: true }),
@@ -305,6 +331,8 @@ function EditAgentDrawerBody({
name: values.name,
description: values.description || null,
default_model: defaultModel,
+ is_shared: values.is_shared ?? false,
+ color: nextColor,
});
onClose();
} catch (err) {
@@ -312,7 +340,16 @@ function EditAgentDrawerBody({
} finally {
setSaving(false);
}
- }, [agent.agent_id, agentConfig, form, onClose, onSaved, pathMappings, t]);
+ }, [
+ agent.agent_id,
+ agentConfig,
+ colorPalette,
+ form,
+ onClose,
+ onSaved,
+ pathMappings,
+ t,
+ ]);
useEffect(() => {
onSaveReady(handleSave);
@@ -505,6 +542,22 @@ function EditAgentDrawerBody({
>
+
+
+
+
+
+
;
- onCreated: (agentId: string) => void;
+ onRequestCreate: (expert: MarketExpert) => void;
}
const SCENE_ALL = "";
@@ -69,7 +69,7 @@ function promptText(
export default function ExpertMarketTab({
lang,
installedExpertIds,
- onCreated,
+ onRequestCreate,
}: ExpertMarketTabProps) {
const { t } = useTranslation();
const [items, setItems] = useState([]);
@@ -81,7 +81,6 @@ export default function ExpertMarketTab({
const [errorMessage, setErrorMessage] = useState(null);
const [selected, setSelected] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
- const [creatingSlug, setCreatingSlug] = useState(null);
const debounceRef = useRef | null>(null);
const fetchMarket = useCallback(
@@ -136,31 +135,12 @@ export default function ExpertMarketTab({
[t],
);
- const createMarketExpert = useCallback(
- async (expert: MarketExpert) => {
- if (creatingSlug) return;
- setCreatingSlug(expert.slug);
- try {
- const result = await expertMarketApi.install(expert.slug);
- const enrichment = result.market?.welcome_enrichment;
- if (enrichment === "pending") {
- message.success(
- t("experts.marketCreateSuccessEnriching", { name: result.name }),
- );
- } else {
- message.success(
- t("experts.marketCreateSuccess", { name: result.name }),
- );
- }
- setSelected(null);
- onCreated(result.agent_id);
- } catch (err) {
- message.error(apiErrorMessage(err, t("experts.createFailed"), t));
- } finally {
- setCreatingSlug(null);
- }
+ const openCreate = useCallback(
+ (expert: MarketExpert) => {
+ setSelected(null);
+ onRequestCreate(expert);
},
- [creatingSlug, onCreated, t],
+ [onRequestCreate],
);
const totalText = useMemo(
@@ -334,11 +314,9 @@ export default function ExpertMarketTab({
size="small"
type="primary"
icon={}
- loading={creatingSlug === expert.slug}
- disabled={Boolean(creatingSlug)}
onClick={(e) => {
e.stopPropagation();
- void createMarketExpert(expert);
+ openCreate(expert);
}}
>
{installed
@@ -365,9 +343,7 @@ export default function ExpertMarketTab({
size="large"
block
icon={}
- loading={creatingSlug === selected.slug}
- disabled={Boolean(creatingSlug)}
- onClick={() => void createMarketExpert(selected)}
+ onClick={() => openCreate(selected)}
>
{installedExpertIds.has(selected.id)
? t("experts.createAgainFromMarket")
diff --git a/dashboard/src/pages/Experts/components/PublishExpertDrawer.tsx b/dashboard/src/pages/Experts/components/PublishExpertDrawer.tsx
new file mode 100644
index 00000000..90ae4506
--- /dev/null
+++ b/dashboard/src/pages/Experts/components/PublishExpertDrawer.tsx
@@ -0,0 +1,228 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, Button, Drawer, Form, Input, Spin } from "antd";
+import { message } from "@/utils/antdMessage";
+
+import { agentChatApi } from "../../../api/modules/agentChat";
+import {
+ publishedExpertsApi,
+ type PublishedExpert,
+ type PublishExpertBody,
+} from "../../../api/modules/publishedExperts";
+import type { OctopAgent } from "../../../context/AgentContext";
+import { apiErrorMessage } from "../../../utils/apiError";
+import { pickLocale } from "../../../utils/localizedText";
+import styles from "../index.module.less";
+
+export interface PublishExpertDrawerProps {
+ open: boolean;
+ mode: "publish" | "refresh";
+ agent: OctopAgent;
+ published: PublishedExpert | null;
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+interface PublishFormValues {
+ name: string;
+ description?: string;
+ welcome_zh?: string;
+ welcome_en?: string;
+}
+
+export default function PublishExpertDrawer({
+ open,
+ mode,
+ agent,
+ published,
+ onClose,
+ onSuccess,
+}: PublishExpertDrawerProps) {
+ const { t } = useTranslation();
+ const [form] = Form.useForm();
+ const [submitting, setSubmitting] = useState(false);
+ const [prefillLoading, setPrefillLoading] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+
+ const loadDefaults = async () => {
+ setPrefillLoading(true);
+ const baseName =
+ mode === "refresh" && published ? published.name : agent.name;
+ const baseDescription =
+ mode === "refresh" && published
+ ? published.description
+ : agent.description || "";
+
+ try {
+ const welcome = await agentChatApi.welcome(agent.agent_id);
+ if (cancelled) return;
+ form.setFieldsValue({
+ name: baseName,
+ description: baseDescription,
+ welcome_zh: pickLocale(welcome.welcome_message, "zh"),
+ welcome_en: pickLocale(welcome.welcome_message, "en"),
+ });
+ } catch {
+ if (cancelled) return;
+ form.setFieldsValue({
+ name: baseName,
+ description: baseDescription,
+ welcome_zh: "",
+ welcome_en: "",
+ });
+ } finally {
+ if (!cancelled) setPrefillLoading(false);
+ }
+ };
+
+ void loadDefaults();
+ return () => {
+ cancelled = true;
+ };
+ }, [open, mode, agent, published, form]);
+
+ const buildBody = (values: PublishFormValues): PublishExpertBody => ({
+ name: values.name.trim(),
+ description: values.description?.trim() || "",
+ welcome_message: {
+ zh: values.welcome_zh?.trim() || "",
+ en: values.welcome_en?.trim() || "",
+ },
+ });
+
+ const handleSubmit = async () => {
+ let values: PublishFormValues;
+ try {
+ values = await form.validateFields();
+ } catch {
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const body = buildBody(values);
+ if (mode === "refresh" && published) {
+ await publishedExpertsApi.refresh(published.id, body);
+ message.success(t("experts.published.updateSuccess"));
+ } else {
+ await publishedExpertsApi.publish(agent.agent_id, body);
+ message.success(t("experts.published.publishSuccessHint"));
+ }
+ onSuccess();
+ onClose();
+ } catch (err) {
+ message.error(
+ apiErrorMessage(
+ err,
+ mode === "refresh"
+ ? t("experts.published.updateFailed")
+ : t("experts.published.publishFailed"),
+ t,
+ ),
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const primaryLabel =
+ mode === "refresh"
+ ? t("experts.published.update")
+ : t("experts.published.publish");
+
+ return (
+
+
+ {t("common.cancel")}
+
+ void handleSubmit()}
+ >
+ {primaryLabel}
+
+
+ }
+ >
+
+ {prefillLoading ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/pages/Experts/components/PublishTemplateButton.tsx b/dashboard/src/pages/Experts/components/PublishTemplateButton.tsx
new file mode 100644
index 00000000..1fd7e8bf
--- /dev/null
+++ b/dashboard/src/pages/Experts/components/PublishTemplateButton.tsx
@@ -0,0 +1,131 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Dropdown, Modal, Tooltip } from "antd";
+import { message } from "@/utils/antdMessage";
+import { Upload } from "lucide-react";
+import {
+ publishedExpertsApi,
+ type PublishedExpert,
+} from "../../../api/modules/publishedExperts";
+import { apiErrorMessage } from "../../../utils/apiError";
+import type { OctopAgent } from "../../../context/AgentContext";
+import PublishExpertDrawer from "./PublishExpertDrawer";
+import styles from "../index.module.less";
+
+interface PublishTemplateButtonProps {
+ agent: OctopAgent;
+ published: PublishedExpert | null;
+ onChanged: () => void;
+ /** Optional class for the icon button (card vs table). */
+ buttonClassName?: string;
+}
+
+export default function PublishTemplateButton({
+ agent,
+ published,
+ onChanged,
+ buttonClassName = styles.agentCard2NameActionBtn,
+}: PublishTemplateButtonProps) {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [drawerMode, setDrawerMode] = useState<"publish" | "refresh">(
+ "publish",
+ );
+
+ const openPublishDrawer = () => {
+ setDrawerMode("publish");
+ setDrawerOpen(true);
+ };
+
+ const openRefreshDrawer = () => {
+ setDrawerMode("refresh");
+ setDrawerOpen(true);
+ };
+
+ const confirmUnpublish = () => {
+ if (!published) return;
+ Modal.confirm({
+ title: t("experts.published.unpublishConfirm"),
+ okText: t("experts.published.unpublish"),
+ cancelText: t("common.cancel"),
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ setLoading(true);
+ try {
+ await publishedExpertsApi.unpublish(published.id);
+ message.success(t("experts.published.unpublishSuccess"));
+ onChanged();
+ } catch (err) {
+ message.error(
+ apiErrorMessage(err, t("experts.published.unpublishFailed"), t),
+ );
+ } finally {
+ setLoading(false);
+ }
+ },
+ });
+ };
+
+ const iconButton = (
+
+
+
+ );
+
+ return (
+ <>
+ {published ? (
+
+
+ {iconButton}
+
+
+ ) : (
+
+ {iconButton}
+
+ )}
+
+ setDrawerOpen(false)}
+ onSuccess={onChanged}
+ />
+ >
+ );
+}
diff --git a/dashboard/src/pages/Experts/components/PublishedExpertCard.tsx b/dashboard/src/pages/Experts/components/PublishedExpertCard.tsx
new file mode 100644
index 00000000..c5f300b4
--- /dev/null
+++ b/dashboard/src/pages/Experts/components/PublishedExpertCard.tsx
@@ -0,0 +1,117 @@
+import { memo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Dropdown, Modal } from "antd";
+import { message } from "@/utils/antdMessage";
+import { MoreHorizontal } from "lucide-react";
+import { iconForName } from "./iconForName";
+import {
+ publishedExpertsApi,
+ type PublishedExpert,
+} from "../../../api/modules/publishedExperts";
+import { apiErrorMessage } from "../../../utils/apiError";
+import styles from "../index.module.less";
+
+interface PublishedExpertCardProps {
+ expert: PublishedExpert;
+ canManage: boolean;
+ onInstall: (expert: PublishedExpert) => void;
+ onChanged: () => void;
+}
+
+export const PublishedExpertCard = memo(function PublishedExpertCard({
+ expert,
+ canManage,
+ onInstall,
+ onChanged,
+}: PublishedExpertCardProps) {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const accent = expert.color || "var(--fn-color-brand)";
+
+ const confirmUnpublish = () => {
+ Modal.confirm({
+ title: t("experts.published.unpublishConfirm"),
+ okText: t("experts.published.unpublish"),
+ cancelText: t("common.cancel"),
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ setLoading(true);
+ try {
+ await publishedExpertsApi.unpublish(expert.id);
+ message.success(t("experts.published.unpublishSuccess"));
+ onChanged();
+ } catch (err) {
+ message.error(
+ apiErrorMessage(err, t("experts.published.unpublishFailed"), t),
+ );
+ } finally {
+ setLoading(false);
+ }
+ },
+ });
+ };
+
+ return (
+ onInstall(expert)}
+ style={{ "--expert-accent": accent } as React.CSSProperties}
+ >
+
+
+ {iconForName(expert.icon_name, 20)}
+
+
+
{expert.name}
+
+ {t("experts.published.badge")}
+
+
+ {canManage && (
+
{
+ domEvent.stopPropagation();
+ confirmUnpublish();
+ },
+ },
+ ],
+ }}
+ trigger={["click"]}
+ disabled={loading}
+ >
+ event.stopPropagation()}
+ >
+
+
+
+ )}
+
+
+ {expert.description || "\u00a0"}
+
+
+
+ {t("experts.published.install")}
+
+ {expert.creator_username && (
+
+ {t("experts.published.by", { name: expert.creator_username })}
+
+ )}
+
+
+ );
+});
diff --git a/dashboard/src/pages/Experts/components/RootDirSelect.tsx b/dashboard/src/pages/Experts/components/RootDirSelect.tsx
index 9bba7bc2..f6a977a2 100644
--- a/dashboard/src/pages/Experts/components/RootDirSelect.tsx
+++ b/dashboard/src/pages/Experts/components/RootDirSelect.tsx
@@ -75,6 +75,8 @@ export default function RootDirSelect({ value, onChange }: RootDirSelectProps) {
const [editingName, setEditingName] = useState("");
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(false);
+ const [loadedKeys, setLoadedKeys] = useState([]);
+ const loadingPathsRef = useRef(new Set());
useEffect(() => {
if (!value) return;
@@ -84,8 +86,15 @@ export default function RootDirSelect({ value, onChange }: RootDirSelectProps) {
const loadData = useCallback>(
async (node) => {
const path = String(node.value ?? "");
- if (!path) return;
+ if (
+ !path ||
+ loadedKeys.includes(path) ||
+ loadingPathsRef.current.has(path)
+ ) {
+ return;
+ }
+ loadingPathsRef.current.add(path);
try {
const data = await request<{ entries: DirEntry[] }>(
`/filesystem/dirs?path=${encodeURIComponent(path)}`,
@@ -98,11 +107,14 @@ export default function RootDirSelect({ value, onChange }: RootDirSelectProps) {
setTreeData(
withSanitizedTree((prev) => appendChildren(prev, path, children)),
);
+ setLoadedKeys((prev) => (prev.includes(path) ? prev : [...prev, path]));
} catch {
message.error(t("experts.rootDirListFailed"));
+ } finally {
+ loadingPathsRef.current.delete(path);
}
},
- [t],
+ [loadedKeys, t],
);
const beginEditing = useCallback((path: string, name: string) => {
@@ -137,6 +149,9 @@ export default function RootDirSelect({ value, onChange }: RootDirSelectProps) {
renameNode(prev, path, result.path, result.name),
),
);
+ setLoadedKeys((prev) =>
+ prev.map((key) => (key === path ? result.path : key)),
+ );
if (value === path) {
onChange?.(result.path);
}
@@ -305,6 +320,8 @@ export default function RootDirSelect({ value, onChange }: RootDirSelectProps) {
}}
treeData={displayTreeData}
loadData={loadData}
+ treeLoadedKeys={loadedKeys}
+ virtual={false}
treeNodeLabelProp="value"
treeExpandedKeys={expandedKeys}
onTreeExpand={(keys) => setExpandedKeys(keys.map(String))}
diff --git a/dashboard/src/pages/Experts/components/SubagentDrawer.test.ts b/dashboard/src/pages/Experts/components/SubagentDrawer.test.ts
new file mode 100644
index 00000000..33884daf
--- /dev/null
+++ b/dashboard/src/pages/Experts/components/SubagentDrawer.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { buildSubagentMarkdown, parseSubagentForm } from "./SubagentDrawer";
+import {
+ expertPaletteColor,
+ resolveExpertPalette,
+} from "../../../utils/expertColor";
+import { DEFAULT_PALETTE } from "../../../styles/themePalettes";
+
+describe("SubagentDrawer color frontmatter", () => {
+ it("round-trips palette hex through markdown build/parse", () => {
+ const color = expertPaletteColor("tech");
+ const md = buildSubagentMarkdown({
+ slug: "helper",
+ name: "Helper",
+ description: "Helps",
+ emoji: "🧰",
+ color,
+ body: "Do the thing.",
+ });
+ expect(md).toMatch(/color:\s*"?#4B74FA"?/);
+ expect(parseSubagentForm(md, "helper").color).toBe(color);
+ });
+
+ it("maps empty form color to default palette for the picker", () => {
+ expect(resolveExpertPalette("")).toBe(DEFAULT_PALETTE);
+ expect(resolveExpertPalette(undefined)).toBe(DEFAULT_PALETTE);
+ });
+
+ it("maps picker palette back to swatch hex for the form field", () => {
+ expect(expertPaletteColor(resolveExpertPalette("#4B74FA"))).toBe(
+ expertPaletteColor("tech"),
+ );
+ });
+});
diff --git a/dashboard/src/pages/Experts/components/SubagentDrawer.tsx b/dashboard/src/pages/Experts/components/SubagentDrawer.tsx
index 20b8e117..02ac7edf 100644
--- a/dashboard/src/pages/Experts/components/SubagentDrawer.tsx
+++ b/dashboard/src/pages/Experts/components/SubagentDrawer.tsx
@@ -3,7 +3,14 @@ import { Button, Drawer, Form, Input, Segmented, Spin } from "antd";
import { message } from "@/utils/antdMessage";
import type { FormInstance } from "antd";
import { useTranslation } from "react-i18next";
+import ExpertColorPicker from "../../../components/ExpertColorPicker";
+import EmojiPicker from "../../../components/EmojiPicker";
+import {
+ expertPaletteColor,
+ resolveExpertPalette,
+} from "../../../utils/expertColor";
import { splitMarkdownFrontmatter } from "../../../utils/markdown";
+import { DEFAULT_PALETTE } from "../../../styles/themePalettes";
import styles from "./SubagentDrawer.module.less";
export interface SubagentFormValues {
@@ -26,6 +33,22 @@ type EditorTab = "form" | "source";
export const SUBAGENT_SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
+/** Form-bound adapter: stores hex in the form, shows curated swatches. */
+function SubagentColorField({
+ value,
+ onChange,
+}: {
+ value?: string;
+ onChange?: (hex: string) => void;
+}) {
+ return (
+ onChange?.(expertPaletteColor(palette))}
+ />
+ );
+}
+
function yamlQuote(value: string): string {
if (!value) return '""';
if (/[:#\n"'{}[\],&*?|>!%@`]/.test(value) || value.trim() !== value) {
@@ -125,7 +148,7 @@ export function SubagentDrawer({
name: "",
description: "",
emoji: "🤖",
- color: "",
+ color: expertPaletteColor(DEFAULT_PALETTE),
body: t("subagents.newBodyTemplate"),
content: "",
});
@@ -280,14 +303,19 @@ export function SubagentDrawer({
autoSize={{ minRows: 2, maxRows: 4 }}
/>
-
-
+
+
-
-
+
+
diff --git a/dashboard/src/pages/Experts/components/SubagentManager.tsx b/dashboard/src/pages/Experts/components/SubagentManager.tsx
index 432e0f5a..f7d29c1f 100644
--- a/dashboard/src/pages/Experts/components/SubagentManager.tsx
+++ b/dashboard/src/pages/Experts/components/SubagentManager.tsx
@@ -2,7 +2,13 @@
* SubagentManager — reusable subagent management content.
* Used inside SubagentCatalogDrawer (Drawer) and Subagents page.
*/
-import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type CSSProperties,
+} from "react";
import { Alert, Empty, Form, Input, Modal, Spin, Tabs } from "antd";
import { message } from "@/utils/antdMessage";
@@ -22,6 +28,10 @@ import { request } from "../../../api/request";
import { workspaceApi } from "../../../api/modules/workspace";
import { apiErrorMessage } from "../../../utils/apiError";
import { isAgentChatReady } from "../../../utils/agentError";
+import {
+ resolveSubagentAccent,
+ subagentAccentIconStyle,
+} from "../../../utils/expertColor";
import { withFromWorkspace } from "../../../utils/fromWorkspace";
import { normalizeUiLocale } from "../../../utils/locale";
import { pickLocale } from "../../../utils/localizedText";
@@ -360,13 +370,18 @@ export default function SubagentManager({
{filteredCatalogItems.map((item) => {
const installed = installedSlugs.has(item.slug);
- const accent = item.color?.startsWith("#") ? item.color : "#6366f1";
+ const accent = resolveSubagentAccent(item.color);
return (
-
+
+
{item.emoji ?? "🤖"}
@@ -462,13 +477,18 @@ export default function SubagentManager({
return (
{filteredInstalled.map((subagent) => {
- const accent = "#6366f1";
+ const accent = resolveSubagentAccent(subagent.color);
return (
-
+
+
{subagent.emoji ?? "🤖"}
diff --git a/dashboard/src/pages/Experts/components/expertFileGroups.test.ts b/dashboard/src/pages/Experts/components/expertFileGroups.test.ts
new file mode 100644
index 00000000..0ceadcb9
--- /dev/null
+++ b/dashboard/src/pages/Experts/components/expertFileGroups.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import { groupExpertFiles } from "./expertFileGroups";
+
+describe("groupExpertFiles", () => {
+ it("separates config markdown, skills, and subagents", () => {
+ const { configFiles, skillGroups, subagentFiles } = groupExpertFiles([
+ { name: "SOUL.md", content: "# Soul" },
+ { name: "AGENTS.md", content: "# Agents guide" },
+ {
+ name: "skills/demo/SKILL.md",
+ content:
+ '---\nname: Demo Skill\ndescription: A demo\nmetadata:\n octop:\n emoji: "🎯"\n---\n\n# Skill',
+ },
+ {
+ name: "agents/reviewer.md",
+ content:
+ "---\nname: Reviewer\ndescription: Review code\nemoji: 🔍\n---\n\n# Body\n",
+ },
+ ]);
+
+ expect(configFiles.map((f) => f.name)).toEqual(["SOUL.md", "AGENTS.md"]);
+ expect(skillGroups).toHaveLength(1);
+ expect(skillGroups[0]?.name).toBe("demo");
+ expect(skillGroups[0]?.emoji).toBe("🎯");
+ expect(skillGroups[0]?.displayName).toBe("Demo Skill");
+ expect(skillGroups[0]?.description).toBe("A demo");
+ expect(subagentFiles).toHaveLength(1);
+ expect(subagentFiles[0]?.slug).toBe("reviewer");
+ expect(subagentFiles[0]?.name).toBe("Reviewer");
+ expect(subagentFiles[0]?.description).toBe("Review code");
+ expect(subagentFiles[0]?.emoji).toBe("🔍");
+ });
+
+ it("excludes manifest.json from config preview", () => {
+ const { configFiles } = groupExpertFiles([
+ { name: "SOUL.md", content: "# Soul" },
+ { name: "manifest.json", content: "{}" },
+ ]);
+ expect(configFiles.map((f) => f.name)).toEqual(["SOUL.md"]);
+ });
+});
diff --git a/dashboard/src/pages/Experts/components/expertFileGroups.ts b/dashboard/src/pages/Experts/components/expertFileGroups.ts
index 157055d9..2559f85d 100644
--- a/dashboard/src/pages/Experts/components/expertFileGroups.ts
+++ b/dashboard/src/pages/Experts/components/expertFileGroups.ts
@@ -1,5 +1,7 @@
import { request } from "../../../api/request";
import { withFromWorkspace } from "../../../utils/fromWorkspace";
+import { parseSkillPreviewFromMarkdown } from "../../Agent/Skills/skillMarkdown";
+import { parseSubagentForm } from "./SubagentDrawer";
export interface NamedFileContent {
name: string;
@@ -9,27 +11,84 @@ export interface NamedFileContent {
export interface SkillFileGroup {
name: string;
files: NamedFileContent[];
+ emoji: string;
+ displayName: string;
+ description: string;
}
-/** Split expert template files into root config md and skills/ tree. */
+export interface SubagentFilePreview {
+ slug: string;
+ name: string;
+ description: string;
+ emoji: string;
+ file: NamedFileContent;
+}
+
+function isSubagentPath(name: string): boolean {
+ return name.startsWith("agents/") && name.endsWith(".md");
+}
+
+function isPromptMdPath(name: string): boolean {
+ return !name.includes("/") && name.endsWith(".md");
+}
+
+/** Split expert template files into persona md, skills/, and subagent definitions. */
export function groupExpertFiles(files: NamedFileContent[]): {
configFiles: NamedFileContent[];
skillGroups: SkillFileGroup[];
+ subagentFiles: SubagentFilePreview[];
} {
- const configFiles = files.filter((f) => !f.name.startsWith("skills/"));
+ const configFiles = files.filter(
+ (f) =>
+ isPromptMdPath(f.name) &&
+ f.name !== "manifest.json" &&
+ !f.name.startsWith("skills/") &&
+ !isSubagentPath(f.name),
+ );
const skillFiles = files.filter((f) => f.name.startsWith("skills/"));
+ const subagentRaw = files.filter((f) => isSubagentPath(f.name));
const groups: Record
= {};
for (const file of skillFiles) {
const skillName = file.name.split("/")[1];
if (!skillName) continue;
if (!groups[skillName]) {
- groups[skillName] = { name: skillName, files: [] };
+ groups[skillName] = {
+ name: skillName,
+ files: [],
+ emoji: "✨",
+ displayName: skillName,
+ description: "",
+ };
}
groups[skillName].files.push(file);
}
- return { configFiles, skillGroups: Object.values(groups) };
+ for (const group of Object.values(groups)) {
+ const skillMd = group.files.find((f) => f.name.endsWith("/SKILL.md"))
+ ?.content;
+ if (!skillMd) continue;
+ const preview = parseSkillPreviewFromMarkdown(skillMd, group.name);
+ group.emoji = preview.emoji;
+ group.displayName = preview.name;
+ group.description = preview.description;
+ }
+
+ const subagentFiles = subagentRaw
+ .map((file) => {
+ const slug = file.name.slice("agents/".length, -".md".length);
+ const parsed = parseSubagentForm(file.content, slug);
+ return {
+ slug,
+ name: parsed.name || slug,
+ description: parsed.description,
+ emoji: parsed.emoji || "🤖",
+ file,
+ };
+ })
+ .sort((a, b) => a.slug.localeCompare(b.slug));
+
+ return { configFiles, skillGroups: Object.values(groups), subagentFiles };
}
/** Workspace glob entry from GET /workspace/glob. */
diff --git a/dashboard/src/pages/Experts/components/rootDirTree.test.ts b/dashboard/src/pages/Experts/components/rootDirTree.test.ts
index 8a96dc3e..e186d4ee 100644
--- a/dashboard/src/pages/Experts/components/rootDirTree.test.ts
+++ b/dashboard/src/pages/Experts/components/rootDirTree.test.ts
@@ -140,6 +140,36 @@ describe("rootDirTree helpers", () => {
expect(pathExistsInTree(next, "/Users/jubaoliang")).toBe(true);
});
+ it("sanitizeTree removes duplicate values anywhere in the tree", () => {
+ const tree: DirTreeNode[] = [
+ {
+ ...root,
+ children: [
+ {
+ value: "/Users",
+ title: "Users",
+ isLeaf: false,
+ children: [
+ {
+ value: "/Users/a",
+ title: "a",
+ isLeaf: false,
+ },
+ {
+ value: "/Users/a",
+ title: "a-dup",
+ isLeaf: false,
+ },
+ ],
+ },
+ ],
+ },
+ ];
+ const next = sanitizeTree(tree);
+ expect(next[0].children?.[0].children).toHaveLength(1);
+ expect(next[0].children?.[0].children?.[0].value).toBe("/Users/a");
+ });
+
it("ancestorDirPaths returns parents from / down to the parent of path", () => {
expect(ancestorDirPaths("/Users/jubaoliang/新建文件夹")).toEqual([
"/",
diff --git a/dashboard/src/pages/Experts/components/rootDirTree.ts b/dashboard/src/pages/Experts/components/rootDirTree.ts
index 5c2c7ca7..2fde44a1 100644
--- a/dashboard/src/pages/Experts/components/rootDirTree.ts
+++ b/dashboard/src/pages/Experts/components/rootDirTree.ts
@@ -38,7 +38,26 @@ export function ensurePathInTree(
/** Keep a single `/` tree — root-level orphans duplicate keys and break expand. */
export function sanitizeTree(nodes: DirTreeNode[]): DirTreeNode[] {
const root = nodes.find((node) => node.value === "/");
- return root ? [root] : nodes;
+ if (!root) return nodes;
+
+ // Ant Design TreeSelect virtual scroll renders duplicate rows when the same
+ // value appears more than once anywhere in treeData (antd#37228).
+ const seen = new Set();
+
+ const walk = (node: DirTreeNode): DirTreeNode | null => {
+ if (seen.has(node.value)) return null;
+ seen.add(node.value);
+ const children = (node.children ?? [])
+ .map(walk)
+ .filter((child): child is DirTreeNode => child != null);
+ return {
+ ...node,
+ children: children.length > 0 ? children : undefined,
+ };
+ };
+
+ const cleaned = walk(root);
+ return cleaned ? [cleaned] : [root];
}
/**
diff --git a/dashboard/src/pages/Experts/components/sharedExpert.test.ts b/dashboard/src/pages/Experts/components/sharedExpert.test.ts
new file mode 100644
index 00000000..ff8ef425
--- /dev/null
+++ b/dashboard/src/pages/Experts/components/sharedExpert.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import {
+ isOwnedExpert,
+ isSharedExpertViewer,
+ ownedExperts,
+} from "../../../utils/sharedExpert";
+
+describe("isSharedExpertViewer", () => {
+ it("identifies a shared expert viewed by someone other than its owner", () => {
+ expect(isSharedExpertViewer({ is_shared: true, is_owner: false })).toBe(
+ true,
+ );
+ });
+
+ it("does not treat the owner or a private expert as a viewer", () => {
+ expect(isSharedExpertViewer({ is_shared: true, is_owner: true })).toBe(
+ false,
+ );
+ expect(isSharedExpertViewer({ is_shared: false, is_owner: false })).toBe(
+ false,
+ );
+ });
+});
+
+describe("ownedExperts", () => {
+ it("keeps owned experts and drops shared viewers for manage pages", () => {
+ const agents = [
+ { agent_id: "own", is_shared: true, is_owner: true },
+ { agent_id: "shared", is_shared: true, is_owner: false },
+ { agent_id: "private", is_shared: false, is_owner: true },
+ ];
+ expect(ownedExperts(agents).map((a) => a.agent_id)).toEqual([
+ "own",
+ "private",
+ ]);
+ expect(isOwnedExpert(agents[1]!)).toBe(false);
+ expect(isOwnedExpert(agents[0]!)).toBe(true);
+ });
+});
diff --git a/dashboard/src/pages/Experts/index.module.less b/dashboard/src/pages/Experts/index.module.less
index 8cd4144f..3bd65e78 100644
--- a/dashboard/src/pages/Experts/index.module.less
+++ b/dashboard/src/pages/Experts/index.module.less
@@ -1359,6 +1359,7 @@
}
.catalogCard {
+ position: relative;
display: flex;
flex-direction: column;
gap: 10px;
@@ -1399,6 +1400,15 @@
}
}
+.catalogCardAccent {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: var(--agent-accent, #6366f1);
+}
+
.catalogCardHeader {
display: flex;
align-items: center;
@@ -1794,3 +1804,27 @@
white-space: nowrap;
flex-shrink: 0;
}
+
+/* ── Publish expert drawer ─────────────────────────────────────── */
+
+.publishDrawerBody {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.publishDrawerLoading {
+ display: flex;
+ justify-content: center;
+ padding: 8px 0 12px;
+}
+
+.publishDrawerHint {
+ margin-bottom: 16px;
+}
+
+.publishDrawerFooter {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
diff --git a/dashboard/src/pages/Experts/index.tsx b/dashboard/src/pages/Experts/index.tsx
index 66a2ce72..ddd0a37e 100644
--- a/dashboard/src/pages/Experts/index.tsx
+++ b/dashboard/src/pages/Experts/index.tsx
@@ -25,17 +25,26 @@ import { message } from "@/utils/antdMessage";
import { LayoutGrid, List, RefreshCw } from "lucide-react";
import PageShell from "../../layouts/PageShell";
import { request } from "../../api/request";
+import {
+ publishedExpertsApi,
+ type PublishedExpert,
+} from "../../api/modules/publishedExperts";
import { useAgent } from "../../context/AgentContext";
+import { useCurrentUser } from "../../hooks/useCurrentUser";
import { useCardTableView } from "../../hooks/useCardTableView";
import type { OctopAgent } from "../../context/AgentContext";
import { AgentCard } from "./components/AgentCard";
import { ExpertCard } from "./components/ExpertCard";
import type { ExpertSummary } from "./components/ExpertCard";
import EditAgentDrawer from "./components/EditAgentDrawer";
-import CreateFromExpertDrawer from "./components/CreateFromExpertDrawer";
+import CreateFromExpertDrawer, {
+ type CreateFromTemplateSource,
+} from "./components/CreateFromExpertDrawer";
+import { PublishedExpertCard } from "./components/PublishedExpertCard";
import AgentExpertsTable from "./components/AgentExpertsTable";
import ExpertMarketTab from "./components/ExpertMarketTab";
import { OctopEmptyMascot } from "../../components/EmptyState";
+import { ownedExperts } from "../../utils/sharedExpert";
import styles from "./index.module.less";
type TabKey = "my" | "library" | "market";
@@ -51,6 +60,10 @@ async function fetchExpertLibrary(): Promise {
return request("/experts");
}
+async function fetchPublishedExperts(): Promise {
+ return publishedExpertsApi.list();
+}
+
async function fetchInstalledExpertIds(): Promise> {
const data = await request<{ config?: { expert_id?: string } }[]>("/agents");
return new Set(
@@ -62,6 +75,14 @@ export default function ExpertsPage() {
const { t, i18n } = useTranslation();
const lang: "zh" | "en" = i18n.language?.startsWith("zh") ? "zh" : "en";
const { agents, refresh: refreshAgents } = useAgent();
+ const currentUser = useCurrentUser();
+
+ const canManagePublished = useCallback(
+ (expert: PublishedExpert) =>
+ currentUser?.role === "admin" ||
+ String(currentUser?.id) === expert.created_by,
+ [currentUser],
+ );
// ── Tab state ──────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState("my");
@@ -73,13 +94,16 @@ export default function ExpertsPage() {
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
- const [agentList, expertList, installedIds] = await Promise.all([
- request("/agents"),
- fetchExpertLibrary(),
- fetchInstalledExpertIds(),
- ]);
- setLocalAgents(agentList);
+ const [agentList, expertList, publishedList, installedIds] =
+ await Promise.all([
+ request("/agents"),
+ fetchExpertLibrary(),
+ fetchPublishedExperts(),
+ fetchInstalledExpertIds(),
+ ]);
+ setLocalAgents(ownedExperts(agentList));
setExperts(expertList);
+ setPublishedExperts(publishedList);
setAgentExpertIds(installedIds);
await refreshAgents({ silent: true, force: true });
} catch (err: unknown) {
@@ -100,13 +124,41 @@ export default function ExpertsPage() {
// ── Built-in expert library ────────────────────────────────────
const [experts, setExperts] = useState([]);
const [expertLoading, setExpertLoading] = useState(false);
+ const [publishedExperts, setPublishedExperts] = useState(
+ [],
+ );
+ const [publishedExpertLoading, setPublishedExpertLoading] = useState(false);
+
+ const publishedByAgentId = useMemo(() => {
+ const map: Record = {};
+ for (const item of publishedExperts) {
+ if (item.source_agent_id) {
+ map[item.source_agent_id] = item;
+ }
+ }
+ return map;
+ }, [publishedExperts]);
+
+ const refreshPublishedExperts = useCallback(async () => {
+ try {
+ setPublishedExperts(await fetchPublishedExperts());
+ } catch (err: unknown) {
+ message.error(
+ err instanceof Error ? err.message : t("experts.loadFailed"),
+ );
+ }
+ }, [t]);
useEffect(() => {
let cancelled = false;
setExpertLoading(true);
- fetchExpertLibrary()
- .then((data) => {
- if (!cancelled) setExperts(data);
+ setPublishedExpertLoading(true);
+ Promise.all([fetchExpertLibrary(), fetchPublishedExperts()])
+ .then(([expertData, publishedData]) => {
+ if (!cancelled) {
+ setExperts(expertData);
+ setPublishedExperts(publishedData);
+ }
})
.catch((err: unknown) => {
if (cancelled) return;
@@ -115,7 +167,10 @@ export default function ExpertsPage() {
);
})
.finally(() => {
- if (!cancelled) setExpertLoading(false);
+ if (!cancelled) {
+ setExpertLoading(false);
+ setPublishedExpertLoading(false);
+ }
});
return () => {
cancelled = true;
@@ -123,11 +178,12 @@ export default function ExpertsPage() {
}, [t]);
// ── Local agent state (extends AgentContext for optimistic updates) ──
- const [localAgents, setLocalAgents] = useState(agents);
+ const ownedAgents = useMemo(() => ownedExperts(agents), [agents]);
+ const [localAgents, setLocalAgents] = useState(ownedAgents);
const [newAgentId, setNewAgentId] = useState(null);
useEffect(() => {
- setLocalAgents(agents);
+ setLocalAgents(ownedExperts(agents));
}, [agents]);
const handleStateChange = useCallback((agentId: string, newState: string) => {
@@ -148,7 +204,12 @@ export default function ExpertsPage() {
(
updated: Pick<
OctopAgent,
- "agent_id" | "name" | "description" | "default_model"
+ | "agent_id"
+ | "name"
+ | "description"
+ | "default_model"
+ | "is_shared"
+ | "color"
>,
) => {
setEditAgent(null);
@@ -160,6 +221,8 @@ export default function ExpertsPage() {
name: updated.name,
description: updated.description,
default_model: updated.default_model,
+ is_shared: updated.is_shared,
+ color: updated.color,
}
: a,
),
@@ -170,11 +233,12 @@ export default function ExpertsPage() {
);
// ── Create-from-expert Drawer / Market create success ──────────
- const [createExpert, setCreateExpert] = useState(null);
+ const [createSource, setCreateSource] =
+ useState(null);
const handleCreated = useCallback(
- (agentId: string) => {
- setCreateExpert(null);
+ (agentId: string, _agentName?: string) => {
+ setCreateSource(null);
void refreshAgents({ silent: true });
setActiveTab("my");
setNewAgentId(agentId);
@@ -298,6 +362,10 @@ export default function ExpertsPage() {
agent={agent}
iconName={agent.icon_name}
accentColor={agent.color}
+ publishedExpert={publishedByAgentId[agent.agent_id] ?? null}
+ onPublishedChange={() => {
+ void refreshPublishedExperts();
+ }}
onEdit={(id) =>
setEditAgent(
localAgents.find((a) => a.agent_id === id) ?? null,
@@ -312,6 +380,10 @@ export default function ExpertsPage() {
) : (
{
+ void refreshPublishedExperts();
+ }}
onEdit={(id) =>
setEditAgent(localAgents.find((a) => a.agent_id === id) ?? null)
}
@@ -326,20 +398,22 @@ export default function ExpertsPage() {
localAgents,
newAgentId,
openExpertLibrary,
+ publishedByAgentId,
refreshButton,
+ refreshPublishedExperts,
showCardView,
t,
]);
const libraryContent = useMemo(() => {
- if (expertLoading) {
+ if (expertLoading || publishedExpertLoading) {
return (
);
}
- if (experts.length === 0) {
+ if (experts.length === 0 && publishedExperts.length === 0) {
return (
@@ -353,12 +427,56 @@ export default function ExpertsPage() {
}
return (
<>
+ {publishedExperts.length > 0 && (
+ <>
+
+
+ {t("experts.published.listTitle", {
+ count: publishedExperts.length,
+ })}
+
+
+
+ {t("experts.published.listHint")}
+
+
+ {publishedExperts.map((expert) => (
+
+ setCreateSource({ kind: "published", expert: item })
+ }
+ onChanged={refreshPublishedExperts}
+ />
+ ))}
+
+ >
+ )}
{t("experts.totalLibrary", { count: experts.length })}
{refreshButton}
+ {publishedExperts.length === 0 && (
+
+ {t("experts.published.emptyHint")}
+
+ )}
{experts.map((expert) => (
+ setCreateSource({ kind: "builtin", expert: item })
+ }
/>
))}
>
);
- }, [experts, expertLoading, lang, agentExpertIds, refreshButton, t]);
+ }, [
+ agentExpertIds,
+ expertLoading,
+ experts,
+ lang,
+ publishedExpertLoading,
+ publishedExperts,
+ refreshButton,
+ t,
+ ]);
const marketContent = useMemo(
() => (
+ setCreateSource({ kind: "market", expert })
+ }
/>
),
- [agentExpertIds, handleCreated, lang],
+ [agentExpertIds, lang],
);
return (
@@ -420,10 +551,10 @@ export default function ExpertsPage() {
/>
setCreateExpert(null)}
+ onClose={() => setCreateSource(null)}
onCreated={handleCreated}
/>
diff --git a/dashboard/src/pages/KnowledgeBases/index.module.less b/dashboard/src/pages/KnowledgeBases/index.module.less
new file mode 100644
index 00000000..70ff2b04
--- /dev/null
+++ b/dashboard/src/pages/KnowledgeBases/index.module.less
@@ -0,0 +1,959 @@
+.settingsBody {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 16px;
+}
+
+.settingsFieldLabel {
+ color: var(--fn-text-secondary);
+ font-size: 13px;
+}
+
+.settingsHint {
+ display: block;
+ margin-top: 4px;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.onnxModelList {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.onnxModelItem {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: var(--fn-radius-md);
+ background: var(--fn-bg-primary);
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ background 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &:hover {
+ border-color: color-mix(
+ in srgb,
+ var(--fn-color-brand) 40%,
+ var(--fn-border-secondary)
+ );
+ }
+}
+
+.onnxModelItemActive {
+ border-color: var(--fn-color-brand);
+ background: color-mix(in srgb, var(--fn-color-brand) 8%, transparent);
+ box-shadow: 0 0 0 1px
+ color-mix(in srgb, var(--fn-color-brand) 25%, transparent);
+}
+
+.onnxModelInfo {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ flex: 1;
+}
+
+.onnxModelName {
+ overflow: hidden;
+ color: var(--fn-text-primary);
+ font-size: 13px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.onnxModelMeta {
+ color: var(--fn-text-tertiary);
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.showMoreOnnx {
+ height: 36px;
+ padding: 0;
+ color: var(--fn-text-secondary);
+}
+
+.drawerFooter {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.downloadProgressLabel {
+ margin-bottom: 8px;
+ font-size: 13px;
+}
+
+.helpIcon {
+ flex-shrink: 0;
+ color: var(--fn-text-tertiary);
+ cursor: help;
+ vertical-align: -2px;
+}
+
+.modalChecks,
+.memberList,
+.actions,
+.listActions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.switchLabel {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.baseModal :global(.ant-modal-body) {
+ padding-top: 12px;
+}
+
+.baseForm :global(.ant-form-item) {
+ margin-bottom: 16px;
+}
+
+.baseForm :global(.ant-form-item:last-child) {
+ margin-bottom: 0;
+}
+
+.baseForm :global(.ant-form-item-label) {
+ padding-bottom: 6px !important;
+}
+
+.baseForm :global(.ant-form-item-label > label) {
+ height: auto;
+ color: var(--fn-text-secondary);
+ font-size: 13px;
+}
+
+.iconPicker {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.iconPickerItem {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ padding: 10px 6px;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: var(--fn-radius-md);
+ background: var(--fn-bg-primary);
+ color: var(--fn-text-secondary);
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ background 0.15s ease,
+ color 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &:hover {
+ border-color: color-mix(
+ in srgb,
+ var(--fn-color-brand) 40%,
+ var(--fn-border-secondary)
+ );
+ color: var(--fn-text-primary);
+ }
+}
+
+.iconPickerItemActive {
+ border-color: var(--fn-color-brand);
+ background: color-mix(in srgb, var(--fn-color-brand) 8%, transparent);
+ color: var(--fn-color-brand);
+ box-shadow: 0 0 0 1px
+ color-mix(in srgb, var(--fn-color-brand) 25%, transparent);
+}
+
+.iconPickerGlyph {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+}
+
+.iconPickerLabel {
+ overflow: hidden;
+ max-width: 100%;
+ font-size: 12px;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.formOptions {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin-top: 4px;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: var(--fn-radius-md);
+ overflow: hidden;
+}
+
+.formOptionRow {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ background: var(--fn-bg-primary);
+
+ & + & {
+ border-top: 1px solid var(--fn-border-secondary);
+ }
+
+ :global(.ant-switch) {
+ flex-shrink: 0;
+ margin-top: 2px;
+ }
+}
+
+.formOptionCopy {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ flex: 1;
+}
+
+.formOptionHint {
+ color: var(--fn-text-tertiary);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.layout {
+ display: flex;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: var(--fn-radius-lg);
+ background: var(--fn-bg-container);
+}
+
+.layoutResizing {
+ user-select: none;
+ cursor: col-resize;
+}
+
+.baseList {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ flex-shrink: 0;
+ width: var(--knowledge-bases-sidebar-width, 280px);
+ min-height: 0;
+ padding: 16px;
+ background: var(--fn-bg-secondary);
+}
+
+.listPanelHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.listPanelTitle {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--fn-text-primary);
+ font-size: 13px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.listPanelToggle,
+.listPanelExpandBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ padding: 0;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--fn-text-tertiary);
+ cursor: pointer;
+ transition:
+ background 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ background: var(--fn-bg-hover);
+ color: var(--fn-text-primary);
+ }
+}
+
+.listPanelToggle {
+ width: 28px;
+ height: 28px;
+}
+
+.listPanelExpandBtn {
+ position: absolute;
+ top: 16px;
+ left: 12px;
+ z-index: 1;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--fn-border-secondary);
+ background: var(--fn-bg-container);
+ box-shadow: var(--fn-shadow-sm);
+}
+
+.listActions > :first-child {
+ flex: 1;
+}
+
+.list {
+ min-height: 0;
+ overflow-y: auto;
+ padding: 4px 2px;
+}
+
+.list :global(.ant-list-items) {
+ padding-bottom: 8px;
+}
+
+.list :global(.ant-list-item) {
+ border-bottom: none !important;
+}
+
+.listRow {
+ padding: 0 !important;
+ margin: 0 0 12px !important;
+}
+
+.listRow:last-child {
+ margin-bottom: 0 !important;
+}
+
+.listItem {
+ display: flex !important;
+ flex-direction: column;
+ width: 100%;
+ box-sizing: border-box;
+ min-height: 116px;
+ padding: 12px !important;
+ border: 1px solid var(--fn-border-secondary) !important;
+ border-radius: var(--fn-radius-md);
+ background: var(--fn-bg-primary);
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ box-shadow 0.15s ease,
+ transform 0.15s ease,
+ background 0.15s ease;
+
+ &:hover {
+ border-color: color-mix(
+ in srgb,
+ var(--fn-color-brand) 45%,
+ var(--fn-border-secondary)
+ ) !important;
+ box-shadow: var(--fn-shadow-sm);
+ transform: translateY(-1px);
+ }
+}
+
+.active {
+ border-color: color-mix(
+ in srgb,
+ var(--fn-color-brand) 65%,
+ var(--fn-border-secondary)
+ ) !important;
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--fn-color-brand) 30%, transparent),
+ 0 8px 20px color-mix(in srgb, var(--fn-color-brand) 10%, transparent);
+}
+
+.listName {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ overflow: hidden;
+ color: var(--fn-text-primary);
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.listIcon {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ overflow: hidden;
+ border-radius: 8px;
+ color: var(--fn-color-brand);
+ background: color-mix(in srgb, var(--fn-color-brand) 10%, transparent);
+}
+
+.listDescription {
+ display: -webkit-box;
+ min-height: 34px;
+ margin: 6px 0 10px;
+ overflow: hidden;
+ color: var(--fn-text-tertiary);
+ font-size: 12px;
+ line-height: 1.45;
+ word-break: break-word;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.listMeta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: nowrap;
+ gap: 8px;
+ margin-top: auto;
+}
+
+.listCountTag {
+ margin-inline-end: 0 !important;
+ border-color: var(--fn-border-secondary);
+ background: var(--fn-bg-secondary);
+ color: var(--fn-text-tertiary);
+}
+
+.listMetaBadges {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 2px;
+}
+
+.listBadge {
+ display: inline-flex;
+ align-items: center;
+ height: 20px;
+ padding: 0 6px;
+ border-radius: 4px;
+ font-size: 12px;
+ line-height: 20px;
+ white-space: nowrap;
+}
+
+.listBadgeDefaultOpen {
+ color: #1677ff;
+ background: #e6f4ff;
+}
+
+.listBadgeShared {
+ color: #08979c;
+ background: #e6fffb;
+}
+
+.splitDivider {
+ position: relative;
+ flex-shrink: 0;
+ align-self: stretch;
+ width: 8px;
+ margin: 0 -1px;
+ z-index: 2;
+ background: var(--fn-border-secondary);
+}
+
+.resizeHandle {
+ position: absolute;
+ inset: 0;
+ cursor: col-resize;
+ z-index: 1;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 2px;
+ height: 36px;
+ border-radius: 2px;
+ background: var(--fn-border-secondary);
+ opacity: 0.55;
+ transition:
+ opacity 0.15s,
+ background 0.15s;
+ }
+
+ &:hover::after,
+ .layoutResizing &::after {
+ opacity: 1;
+ background: var(--fn-text-tertiary);
+ }
+}
+
+.detail {
+ position: relative;
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+ padding: 16px 20px 0;
+}
+
+.detailListCollapsed {
+ padding-left: 52px;
+}
+
+.detailLoading,
+.centered {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 180px;
+}
+
+.detailLoading {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 48px;
+ background: color-mix(in srgb, var(--fn-bg-container) 72%, transparent);
+ pointer-events: none;
+}
+
+.detailHeader {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex-shrink: 0;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--fn-border-secondary);
+}
+
+.titleRow,
+.titleGroup,
+.sectionHeader {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.titleGroup {
+ justify-content: flex-start;
+ min-width: 0;
+ flex: 1;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.detailTitle {
+ margin: 0 !important;
+ min-width: 0;
+ flex: 0 1 auto;
+}
+
+.titleActions {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.titleActionBtn {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ color: var(--fn-text-tertiary);
+
+ &:hover {
+ color: var(--fn-text-secondary) !important;
+ }
+}
+
+.detailDescription {
+ margin: 0 !important;
+ max-width: 100%;
+}
+
+.detailMeta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ margin-top: 4px;
+ max-width: 100%;
+}
+
+.detailCreator {
+ margin: 0 !important;
+ max-width: 100%;
+ font-size: 12px;
+ line-height: 1.5715;
+}
+
+.detailBody {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+ overflow: auto;
+}
+
+.docsToolbar {
+ margin: 12px 0 8px;
+}
+
+.uploadHint {
+ display: block;
+ margin: 0 0 12px;
+ font-size: 12px;
+}
+
+.docsCount {
+ font-size: 13px;
+ color: var(--fn-text-tertiary);
+}
+
+.viewModeLabel {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.emptyDetail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 20px;
+ height: 100%;
+ min-height: 180px;
+ padding: 80px 24px 24px;
+ text-align: center;
+}
+
+.emptyDetailText {
+ margin: 0;
+ max-width: 320px;
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--fn-text-tertiary);
+}
+
+.emptyLayout {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: auto;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: var(--fn-radius-lg);
+ background: var(--fn-bg-container);
+}
+
+.emptyLayoutMobile {
+ border: none;
+ border-radius: 0;
+ background: transparent;
+}
+
+.emptyGuide {
+ flex: 1;
+}
+
+.setupMascot {
+ display: block;
+ width: 120px;
+ height: 120px;
+ object-fit: contain;
+ user-select: none;
+ -webkit-user-drag: none;
+}
+
+.docCardGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 14px;
+}
+
+.docCard {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+ min-height: 0;
+ padding: 14px;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: var(--fn-radius-md);
+ background: var(--fn-bg-primary);
+ transition:
+ box-shadow 0.2s ease,
+ border-color 0.2s ease;
+
+ &:hover {
+ border-color: color-mix(
+ in srgb,
+ var(--fn-color-brand) 45%,
+ var(--fn-border-secondary)
+ );
+ box-shadow: var(--fn-shadow-sm);
+ }
+}
+
+.docCardHeader {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ min-width: 0;
+}
+
+.docFormatIcon,
+.docTableIcon {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+}
+
+.docFormatIcon {
+ width: 28px;
+ height: 28px;
+ margin-top: 1px;
+ border-radius: 7px;
+}
+
+.docTableIcon {
+ width: 22px;
+ height: 22px;
+ border-radius: 6px;
+}
+
+.docCardTitleBlock {
+ flex: 1;
+ min-width: 0;
+}
+
+.docCardTitleRow {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.docCardName {
+ overflow: hidden;
+ color: var(--fn-text-primary);
+ font-weight: 600;
+ font-size: 13px;
+ line-height: 1.35;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.docExtBadge {
+ flex-shrink: 0;
+ padding: 0 5px;
+ border-radius: 4px;
+ background: var(--fn-bg-secondary);
+ color: var(--fn-text-tertiary);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ line-height: 16px;
+}
+
+.docCardMeta {
+ margin-top: 3px;
+ color: var(--fn-text-tertiary);
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.docCardActions {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 0;
+}
+
+.docCardFooter {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-width: 0;
+}
+
+.docStatusTag {
+ margin-inline-end: 0 !important;
+ padding-inline: 6px;
+ font-size: 12px;
+ line-height: 18px;
+}
+
+.docUpdatedAt {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--fn-text-tertiary);
+ font-size: 12px;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tableFilename {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ max-width: 100%;
+
+ > span:nth-child(2) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.tableActions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0;
+}
+
+.iconPreview {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ color: var(--fn-color-brand);
+}
+
+.previewBody {
+ max-height: min(60vh, 560px);
+ margin: 0;
+ overflow: auto;
+ padding: 12px 14px;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: var(--fn-radius-md);
+ background: var(--fn-bg-secondary);
+ color: var(--fn-text-primary);
+ font-size: 13px;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.memberList {
+ margin-top: 8px;
+}
+
+.fileInput {
+ display: none;
+}
+
+.limitAlert {
+ margin: 0 0 12px;
+}
+
+.featureBackend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ margin-bottom: 12px;
+}
+
+.featureFields {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.mobileBack {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ margin-top: 2px;
+ margin-right: 2px;
+ padding: 0;
+ border: 0;
+ border-radius: 8px;
+ color: var(--fn-text-secondary);
+ background: transparent;
+ cursor: pointer;
+
+ &:hover {
+ background: var(--fn-bg-hover);
+ color: var(--fn-text-primary);
+ }
+}
+
+@media (max-width: 767px) {
+ .layoutMobile {
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ }
+
+ .layoutMobile .baseList {
+ width: 100% !important;
+ flex: 1;
+ min-height: 0;
+ padding: 0;
+ background: transparent;
+ }
+
+ .layoutMobile .listItem {
+ min-height: 104px;
+ }
+
+ .layoutMobile .detail {
+ flex: 1;
+ min-height: 0;
+ padding: 0;
+ border: 1px solid var(--fn-border-secondary);
+ border-radius: var(--fn-radius-lg);
+ background: var(--fn-bg-container);
+ }
+
+ .layoutMobile .detailHeader {
+ padding: 0 4px 12px;
+ }
+
+ .layoutMobile .titleRow {
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .layoutMobile .detailBody {
+ padding: 0 4px;
+ }
+}
diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx
new file mode 100644
index 00000000..5baab2e7
--- /dev/null
+++ b/dashboard/src/pages/KnowledgeBases/index.tsx
@@ -0,0 +1,1727 @@
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type CSSProperties,
+} from "react";
+import {
+ Alert,
+ Button,
+ Drawer,
+ Empty,
+ Form,
+ Input,
+ List,
+ Modal,
+ Popconfirm,
+ Progress,
+ Radio,
+ Segmented,
+ Select,
+ Spin,
+ Switch,
+ Table,
+ Tag,
+ Tooltip,
+ Typography,
+} from "antd";
+import { message } from "@/utils/antdMessage";
+import {
+ ChevronLeft,
+ Download,
+ Eye,
+ FileUp,
+ LayoutGrid,
+ List as ListIcon,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Pencil,
+ Plus,
+ RefreshCw,
+ Settings,
+ Trash2,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+import { useCurrentUser } from "../../hooks/useCurrentUser";
+import { userCan } from "../../utils/permissions";
+import {
+ DEFAULT_KNOWLEDGE_LIMITS,
+ knowledgeBasesApi,
+ type KnowledgeBase,
+ type KnowledgeCapability,
+ type KnowledgeDocument,
+ type KnowledgeOnnxModel,
+} from "../../api/modules/knowledgeBases";
+import { OctopEmptyMascot } from "../../components/EmptyState";
+import StreamSetupGuide from "../../components/StreamSetupGuide/StreamSetupGuide";
+import { CopyableResourceId } from "../../components/CopyableResourceId";
+import { useCardTableView } from "../../hooks/useCardTableView";
+import { useHorizontalResize } from "../../hooks/useHorizontalResize";
+import { useIsMobile } from "../../hooks/useIsMobile";
+import { useListPanelCollapsed } from "../../hooks/useListPanelCollapsed";
+import { useServerTimezone } from "../../hooks/useServerTimezone";
+import PageShell from "../../layouts/PageShell";
+import { apiErrorMessage, isNotFoundApiError } from "../../utils/apiError";
+import { createDetailRequestGate } from "../../utils/detailRequestGate";
+import { formatBytes, formatSizeGb } from "../../utils/embeddingDownload";
+import { fileTreeIconSpec } from "../../utils/fileTreeIcon";
+import { formatServerDateTime } from "../../utils/formatMessageTime";
+import skillStyles from "../Agent/Skills/index.module.less";
+import { KNOWLEDGE_ICON_NAMES, knowledgeIconForName } from "./knowledgeIcons";
+import styles from "./index.module.less";
+
+type BaseFormValues = {
+ name: string;
+ description?: string;
+ icon_name?: string;
+};
+
+type DocsViewMode = "card" | "table";
+const DOCS_VIEW_STORAGE_KEY = "octop:knowledge-bases-docs-view";
+
+const SUPPORTED_DOCUMENT_TYPES = ".md,.txt,.pdf,.docx,.pptx";
+
+function loadDocsViewMode(): DocsViewMode {
+ const stored = localStorage.getItem(DOCS_VIEW_STORAGE_KEY);
+ return stored === "table" ? "table" : "card";
+}
+
+function documentStatusColor(status: KnowledgeDocument["status"]) {
+ if (status === "ready") return "success";
+ if (status === "failed") return "error";
+ if (status === "processing") return "processing";
+ return "default";
+}
+
+function fileExtensionLabel(filename: string): string {
+ const ext = filename.includes(".")
+ ? filename.slice(filename.lastIndexOf(".") + 1)
+ : "";
+ return ext.trim().toUpperCase().slice(0, 5);
+}
+
+function formatKnowledgeOwner(
+ base: Pick<
+ KnowledgeBase,
+ "owner_display_name" | "owner_username" | "owner_user_id"
+ >,
+): string {
+ const displayName = base.owner_display_name?.trim() || "";
+ const username = base.owner_username?.trim() || "";
+ return displayName || username || String(base.owner_user_id);
+}
+
+function DocumentFormatIcon({
+ filename,
+ size = 14,
+ className,
+}: {
+ filename: string;
+ size?: number;
+ className?: string;
+}) {
+ const { Icon, color } = fileTreeIconSpec(filename);
+ return (
+
+
+
+ );
+}
+
+function KnowledgeIconPicker({
+ value,
+ onChange,
+}: {
+ value?: string;
+ onChange?: (value?: string) => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {KNOWLEDGE_ICON_NAMES.map((name) => {
+ const selected = value === name;
+ return (
+ onChange?.(selected ? undefined : name)}
+ title={t(`knowledgeBases.iconLabels.${name}`)}
+ >
+
+ {knowledgeIconForName(name, 18)}
+
+
+ {t(`knowledgeBases.iconLabels.${name}`)}
+
+
+ );
+ })}
+
+ );
+}
+
+export default function KnowledgeBasesPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const isMobile = useIsMobile();
+ const timeZone = useServerTimezone();
+ const user = useCurrentUser();
+ const canConfigureKb = userCan(user, "knowledge_settings");
+ const { viewMode, setViewMode, showCardView } = useCardTableView(
+ loadDocsViewMode(),
+ );
+ const [bases, setBases] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [documents, setDocuments] = useState([]);
+ const [capability, setCapability] = useState(
+ null,
+ );
+ const [catalog, setCatalog] = useState([]);
+ const [onnxDownloading, setOnnxDownloading] = useState(false);
+ const [downloadProgressOpen, setDownloadProgressOpen] = useState(false);
+ const [downloadProgress, setDownloadProgress] = useState(0);
+ const [downloadProgressLabel, setDownloadProgressLabel] = useState("");
+ const [downloadProgressModel, setDownloadProgressModel] = useState("");
+ const onnxDownloadTimer = useRef | null>(null);
+ const [remoteProviders, setRemoteProviders] = useState<
+ {
+ provider_id: string;
+ provider_name: string;
+ models: { id: string; name: string }[];
+ }[]
+ >([]);
+ const [loading, setLoading] = useState(true);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const [mobilePane, setMobilePane] = useState<"list" | "detail">("list");
+ const [baseModalOpen, setBaseModalOpen] = useState(false);
+ const [editingBase, setEditingBase] = useState(false);
+ const [featureModalOpen, setFeatureModalOpen] = useState(false);
+ const [featureEnabledDraft, setFeatureEnabledDraft] = useState(false);
+ const [featureModel, setFeatureModel] = useState();
+ const [featureBackend, setFeatureBackend] = useState<"onnx" | "remote">(
+ "onnx",
+ );
+ const [featureProviderId, setFeatureProviderId] = useState();
+ const [featureOptionsLoading, setFeatureOptionsLoading] = useState(false);
+ const [onnxExpanded, setOnnxExpanded] = useState(false);
+ const [onnxExpanding, setOnnxExpanding] = useState(false);
+ const onnxExpandedRef = useRef(false);
+ const [previewOpen, setPreviewOpen] = useState(false);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [previewFilename, setPreviewFilename] = useState("");
+ const [previewText, setPreviewText] = useState("");
+ const [baseForm] = Form.useForm();
+ const [defaultOpenChecked, setDefaultOpenChecked] = useState(false);
+ const [sharedChecked, setSharedChecked] = useState(false);
+ const uploadRef = useRef(null);
+ const detailRequestGate = useRef(createDetailRequestGate());
+ const {
+ size: sidebarWidth,
+ isResizing,
+ onResizeStart,
+ } = useHorizontalResize({
+ min: 220,
+ max: 480,
+ defaultSize: 280,
+ storageKey: "octop:knowledge-bases:sidebar-width",
+ });
+ const { collapsed: listPanelCollapsed, toggle: toggleListPanel } =
+ useListPanelCollapsed("octop:knowledge-bases:list-collapsed");
+
+ const canManageSelected = Boolean(
+ selected &&
+ user &&
+ (user.role === "admin" || selected.owner_user_id === user.id),
+ );
+ const canWriteSelected = canManageSelected;
+ const usable = Boolean(capability?.usable);
+ const limits = capability?.limits ?? DEFAULT_KNOWLEDGE_LIMITS;
+ const ownedBaseCount = user
+ ? bases.filter((base) => base.owner_user_id === user.id).length
+ : 0;
+ const atBaseLimit = ownedBaseCount >= limits.max_bases_per_owner;
+ const isAtDocumentLimit = documents.length >= limits.max_docs_per_kb;
+
+ const loadBases = useCallback(async () => {
+ try {
+ const rows = await knowledgeBasesApi.list();
+ setBases(rows);
+ setSelected((current) =>
+ current && !rows.some((row) => row.id === current.id) ? null : current,
+ );
+ } catch (error) {
+ message.error(apiErrorMessage(error, t("knowledgeBases.loadFailed"), t));
+ }
+ }, [t]);
+
+ const loadCapability = useCallback(async () => {
+ try {
+ const nextCapability = await knowledgeBasesApi.getCapability();
+ setCapability(nextCapability);
+ setFeatureModel(nextCapability.selected_model || undefined);
+ setFeatureBackend(nextCapability.backend);
+ setFeatureProviderId(nextCapability.provider_id || undefined);
+ setFeatureEnabledDraft(Boolean(nextCapability.feature_enabled));
+ } catch (error) {
+ message.error(apiErrorMessage(error, t("knowledgeBases.loadFailed"), t));
+ }
+ }, [t]);
+
+ const loadEmbeddingOptions = useCallback(
+ async (allOnnx = false) => {
+ if (allOnnx) {
+ setOnnxExpanding(true);
+ } else {
+ setFeatureOptionsLoading(true);
+ }
+ try {
+ const options = await knowledgeBasesApi.getEmbeddingOptions({
+ allOnnx,
+ });
+ setCatalog(options.onnx);
+ setRemoteProviders(options.remote);
+ if (allOnnx) {
+ onnxExpandedRef.current = true;
+ setOnnxExpanded(true);
+ }
+ return options;
+ } catch (error) {
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.loadFailed"), t),
+ );
+ return undefined;
+ } finally {
+ setFeatureOptionsLoading(false);
+ setOnnxExpanding(false);
+ }
+ },
+ [t],
+ );
+
+ const stopOnnxDownloadWatch = useCallback(() => {
+ if (onnxDownloadTimer.current) {
+ clearInterval(onnxDownloadTimer.current);
+ onnxDownloadTimer.current = null;
+ }
+ }, []);
+
+ const applyOnnxDownloadProgress = useCallback(
+ (modelId: string, status: string, progress: number) => {
+ const pct = Math.max(0, Math.min(100, Math.round((progress || 0) * 100)));
+ setDownloadProgress(pct);
+ if (status === "loading") {
+ setDownloadProgressLabel(
+ t("models.onnxDownloadLoading", { model: modelId }),
+ );
+ } else if (status === "downloading") {
+ setDownloadProgressLabel(
+ t("models.onnxDownloadProgress", { model: modelId, percent: pct }),
+ );
+ } else {
+ setDownloadProgressLabel(t("models.localDownloadPreparing"));
+ }
+ },
+ [t],
+ );
+
+ const finishOnnxDownload = useCallback(
+ async (modelId: string, status: string, error?: string | null) => {
+ stopOnnxDownloadWatch();
+ setOnnxDownloading(false);
+ setDownloadProgressOpen(false);
+ await loadEmbeddingOptions(onnxExpandedRef.current);
+ if (status === "done") {
+ try {
+ await knowledgeBasesApi.activateOnnx(modelId);
+ message.success(t("knowledgeBases.onnxServiceEnabled"));
+ } catch (activateError) {
+ message.success(t("models.onnxDownloadDone", { model: modelId }));
+ message.warning(
+ apiErrorMessage(
+ activateError,
+ t("knowledgeBases.featureSaveFailed"),
+ t,
+ ),
+ );
+ }
+ return;
+ }
+ message.error(error || t("models.onnxDownloadFailed"));
+ },
+ [loadEmbeddingOptions, stopOnnxDownloadWatch, t],
+ );
+
+ const watchOnnxDownloadStatus = useCallback(
+ (modelId: string) => {
+ stopOnnxDownloadWatch();
+ let inFlight = false;
+ let stopped = false;
+ const tick = async () => {
+ if (inFlight || stopped) return;
+ inFlight = true;
+ try {
+ const state = await knowledgeBasesApi.getOnnxDownloadStatus();
+ applyOnnxDownloadProgress(
+ state.model_name || modelId,
+ state.status,
+ state.progress,
+ );
+ if (state.status === "done" || state.status === "failed") {
+ stopped = true;
+ stopOnnxDownloadWatch();
+ await finishOnnxDownload(
+ state.model_name || modelId,
+ state.status,
+ state.error,
+ );
+ }
+ } catch (error) {
+ stopped = true;
+ stopOnnxDownloadWatch();
+ await finishOnnxDownload(
+ modelId,
+ "failed",
+ error instanceof Error ? error.message : String(error),
+ );
+ } finally {
+ inFlight = false;
+ }
+ };
+ onnxDownloadTimer.current = setInterval(() => {
+ void tick();
+ }, 500);
+ void tick();
+ },
+ [applyOnnxDownloadProgress, finishOnnxDownload, stopOnnxDownloadWatch],
+ );
+
+ useEffect(() => () => stopOnnxDownloadWatch(), [stopOnnxDownloadWatch]);
+
+ const loadDetail = useCallback(
+ async (id: string, options?: { silent?: boolean }) => {
+ const requestId = detailRequestGate.current.begin();
+ if (!options?.silent) setDetailLoading(true);
+ try {
+ const [base, nextDocuments] = await Promise.all([
+ knowledgeBasesApi.get(id),
+ knowledgeBasesApi.listDocuments(id),
+ ]);
+ if (!detailRequestGate.current.isCurrent(requestId)) return;
+ setSelected(base);
+ setDocuments(nextDocuments);
+ } catch (error) {
+ if (!detailRequestGate.current.isCurrent(requestId)) return;
+ if (isNotFoundApiError(error)) {
+ setSelected(null);
+ setDocuments([]);
+ return;
+ }
+ if (!options?.silent) {
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.loadFailed"), t),
+ );
+ }
+ } finally {
+ if (
+ !options?.silent &&
+ detailRequestGate.current.isCurrent(requestId)
+ ) {
+ setDetailLoading(false);
+ }
+ }
+ },
+ [t],
+ );
+
+ useEffect(() => {
+ void Promise.all([loadBases(), loadCapability()]).finally(() =>
+ setLoading(false),
+ );
+ }, [loadBases, loadCapability]);
+
+ useEffect(() => {
+ const indexing = documents.some(
+ (document) =>
+ document.status === "pending" || document.status === "processing",
+ );
+ if (!selected || !indexing) return;
+ const timer = window.setInterval(() => {
+ void loadDetail(selected.id, { silent: true });
+ }, 2500);
+ return () => window.clearInterval(timer);
+ }, [documents, loadDetail, selected]);
+ useEffect(() => {
+ if (!isMobile && !selected && bases.length > 0 && !detailLoading) {
+ void loadDetail(bases[0].id);
+ }
+ }, [bases, detailLoading, isMobile, loadDetail, selected]);
+
+ useEffect(() => {
+ if (!isMobile) setMobilePane("list");
+ }, [isMobile]);
+
+ const refresh = async () => {
+ setRefreshing(true);
+ try {
+ await Promise.all([
+ loadBases(),
+ loadCapability(),
+ selected ? loadDetail(selected.id) : Promise.resolve(),
+ ]);
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
+ const selectBase = (base: KnowledgeBase) => {
+ if (base.id !== selected?.id) void loadDetail(base.id);
+ if (isMobile) setMobilePane("detail");
+ };
+
+ const openCreate = () => {
+ if (atBaseLimit) {
+ message.warning(
+ t("knowledgeBases.baseLimitReached", {
+ count: limits.max_bases_per_owner,
+ }),
+ );
+ return;
+ }
+ baseForm.setFieldsValue({
+ name: "",
+ description: "",
+ icon_name: "book-open",
+ });
+ setDefaultOpenChecked(false);
+ setSharedChecked(false);
+ setEditingBase(false);
+ setBaseModalOpen(true);
+ };
+
+ const openEdit = () => {
+ if (!selected) return;
+ baseForm.setFieldsValue({
+ name: selected.name,
+ description: selected.description,
+ icon_name: selected.icon_name || undefined,
+ });
+ setDefaultOpenChecked(selected.default_open);
+ setSharedChecked(selected.shared);
+ setEditingBase(true);
+ setBaseModalOpen(true);
+ };
+
+ const saveBase = async () => {
+ const values = await baseForm.validateFields();
+ const payload = {
+ ...values,
+ default_open: defaultOpenChecked,
+ shared: sharedChecked,
+ };
+ try {
+ const next =
+ editingBase && selected
+ ? await knowledgeBasesApi.update(selected.id, payload)
+ : await knowledgeBasesApi.create(payload);
+ setBaseModalOpen(false);
+ await loadBases();
+ await loadDetail(next.id);
+ if (isMobile) setMobilePane("detail");
+ message.success(
+ t(editingBase ? "knowledgeBases.updated" : "knowledgeBases.created"),
+ );
+ } catch (error) {
+ message.error(apiErrorMessage(error, t("knowledgeBases.saveFailed"), t));
+ }
+ };
+
+ const deleteBase = async () => {
+ if (!selected) return;
+ const deletedId = selected.id;
+ detailRequestGate.current.begin();
+ setSelected(null);
+ setDocuments([]);
+ setDetailLoading(false);
+ try {
+ await knowledgeBasesApi.delete(deletedId);
+ const rows = await knowledgeBasesApi.list();
+ setBases(rows);
+ if (isMobile) {
+ setMobilePane("list");
+ } else if (rows.length > 0) {
+ await loadDetail(rows[0].id);
+ }
+ message.success(t("knowledgeBases.deleted"));
+ } catch (error) {
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.deleteFailed"), t),
+ );
+ }
+ };
+
+ const saveFeature = async (confirmed = false) => {
+ if (!featureEnabledDraft) {
+ try {
+ setCapability(await knowledgeBasesApi.setFeature({ enabled: false }));
+ setFeatureModalOpen(false);
+ message.success(t("knowledgeBases.featureDisabled"));
+ } catch (error) {
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.featureSaveFailed"), t),
+ );
+ }
+ return;
+ }
+ if (!featureModel || (featureBackend === "remote" && !featureProviderId)) {
+ return;
+ }
+ if (
+ featureBackend === "onnx" &&
+ !catalog.find((model) => model.id === featureModel)?.downloaded
+ ) {
+ message.warning(t("knowledgeBases.downloadNeedModel"));
+ return;
+ }
+ if (
+ !confirmed &&
+ capability?.feature_enabled &&
+ (capability.backend !== featureBackend ||
+ capability.selected_model !== featureModel ||
+ capability.provider_id !==
+ (featureBackend === "remote" ? featureProviderId : ""))
+ ) {
+ Modal.confirm({
+ title: t("knowledgeBases.rebuildConfirmTitle"),
+ content: t("knowledgeBases.rebuildConfirmDescription"),
+ onOk: () => void saveFeature(true),
+ });
+ return;
+ }
+ try {
+ const next = await knowledgeBasesApi.setFeature({
+ enabled: true,
+ backend: featureBackend,
+ model: featureModel,
+ provider_id: featureBackend === "remote" ? featureProviderId : "",
+ });
+ setCapability(next);
+ setFeatureModalOpen(false);
+ message.success(t("knowledgeBases.featureEnabled"));
+ } catch (error) {
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.featureSaveFailed"), t),
+ );
+ }
+ };
+
+ const openSettings = () => {
+ const selectedModel = capability?.selected_model || undefined;
+ const selectedBackend = capability?.backend ?? "onnx";
+ onnxExpandedRef.current = false;
+ setOnnxExpanded(false);
+ setFeatureEnabledDraft(Boolean(capability?.feature_enabled));
+ setFeatureBackend(selectedBackend);
+ setFeatureModel(selectedModel);
+ setFeatureProviderId(capability?.provider_id || undefined);
+ setFeatureModalOpen(true);
+ void (async () => {
+ const options = await loadEmbeddingOptions(false);
+ const selectedInCatalog = Boolean(
+ selectedModel &&
+ options?.onnx.some((model) => model.id === selectedModel),
+ );
+ if (selectedBackend === "onnx" && selectedModel && !selectedInCatalog) {
+ await loadEmbeddingOptions(true);
+ }
+ })();
+ void knowledgeBasesApi.getOnnxDownloadStatus().then((state) => {
+ if (state.status === "downloading" || state.status === "loading") {
+ const modelId = state.model_name;
+ setOnnxDownloading(true);
+ setDownloadProgressModel(modelId);
+ setDownloadProgressOpen(true);
+ applyOnnxDownloadProgress(modelId, state.status, state.progress);
+ watchOnnxDownloadStatus(modelId);
+ }
+ });
+ };
+
+ const startOnnxDownload = async (modelId: string) => {
+ const selected = catalog.find((model) => model.id === modelId);
+ const size = formatSizeGb(selected?.size_gb);
+ Modal.confirm({
+ title: t("models.localDownloadConfirmTitle"),
+ content: t("models.localDownloadConfirmOnnx", {
+ name: modelId,
+ size: size || t("knowledgeBases.sizeUnknown"),
+ }),
+ okText: t("knowledgeBases.downloadModel"),
+ cancelText: t("common.cancel"),
+ onOk: async () => {
+ setOnnxDownloading(true);
+ setDownloadProgressModel(modelId);
+ setDownloadProgress(0);
+ setDownloadProgressLabel(t("models.localDownloadPreparing"));
+ setDownloadProgressOpen(true);
+ try {
+ await knowledgeBasesApi.downloadOnnx(modelId);
+ watchOnnxDownloadStatus(modelId);
+ } catch (error) {
+ setOnnxDownloading(false);
+ setDownloadProgressOpen(false);
+ message.error(
+ apiErrorMessage(error, t("models.onnxDownloadFailed"), t),
+ );
+ }
+ },
+ });
+ };
+
+ const dismissDownloadProgressToBackground = () => {
+ setDownloadProgressOpen(false);
+ message.info(t("models.localDownloadBackground"));
+ };
+
+ const uploadDocuments = async (files: FileList | null) => {
+ if (!selected || !files || !usable || isAtDocumentLimit) return;
+ const remaining = Math.max(0, limits.max_docs_per_kb - documents.length);
+ const chosen = Array.from(files).slice(0, remaining);
+ const oversized = chosen.filter(
+ (file) => file.size > limits.max_document_bytes,
+ );
+ if (oversized.length > 0) {
+ message.error(
+ t("knowledgeBases.documentTooLarge", {
+ sizeMb: Math.round(limits.max_document_bytes / (1024 * 1024)),
+ }),
+ );
+ if (uploadRef.current) uploadRef.current.value = "";
+ return;
+ }
+ try {
+ for (const file of chosen) {
+ await knowledgeBasesApi.uploadDocument(selected.id, file);
+ }
+ await loadDetail(selected.id);
+ await loadBases();
+ message.success(t("knowledgeBases.uploaded"));
+ } catch (error) {
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.uploadFailed"), t),
+ );
+ } finally {
+ if (uploadRef.current) uploadRef.current.value = "";
+ }
+ };
+
+ const deleteDocument = async (documentId: string) => {
+ if (!selected) return;
+ try {
+ await knowledgeBasesApi.deleteDocument(selected.id, documentId);
+ await loadDetail(selected.id);
+ await loadBases();
+ } catch (error) {
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.deleteFailed"), t),
+ );
+ }
+ };
+
+ const rebuildDocument = async (documentId: string) => {
+ if (!selected) return;
+ try {
+ await knowledgeBasesApi.reindexDocument(selected.id, documentId);
+ message.success(t("knowledgeBases.rebuildDocumentSuccess"));
+ await loadDetail(selected.id);
+ } catch (error) {
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.rebuildDocumentFailed"), t),
+ );
+ }
+ };
+
+ const openDocumentPreview = async (documentId: string) => {
+ if (!selected) return;
+ setPreviewOpen(true);
+ setPreviewLoading(true);
+ setPreviewFilename("");
+ setPreviewText("");
+ try {
+ const preview = await knowledgeBasesApi.previewDocument(
+ selected.id,
+ documentId,
+ );
+ setPreviewFilename(preview.filename);
+ setPreviewText(
+ preview.text.trim() ? preview.text : t("knowledgeBases.previewEmpty"),
+ );
+ } catch (error) {
+ setPreviewOpen(false);
+ message.error(
+ apiErrorMessage(error, t("knowledgeBases.previewFailed"), t),
+ );
+ } finally {
+ setPreviewLoading(false);
+ }
+ };
+
+ const renderDocumentActions = (document: KnowledgeDocument) => (
+
+
+ }
+ aria-label={t("knowledgeBases.previewDocument")}
+ onClick={() => void openDocumentPreview(document.id)}
+ />
+
+ {canWriteSelected ? (
+ <>
+
void rebuildDocument(document.id)}
+ >
+
+ }
+ aria-label={t("knowledgeBases.rebuildDocument")}
+ />
+
+
+
void deleteDocument(document.id)}
+ >
+ }
+ aria-label={t("common.delete")}
+ />
+
+ >
+ ) : null}
+
+ );
+
+ const showListPane = !isMobile || mobilePane === "list";
+ const showDetailPane = !isMobile || mobilePane === "detail";
+ const showListPanel = showListPane && (isMobile || !listPanelCollapsed);
+ const showEnableGuide = !loading && !usable;
+ const showEmptyGuide = !loading && usable && bases.length === 0;
+ const emptyLayoutClassName = `${styles.emptyLayout}${
+ isMobile ? ` ${styles.emptyLayoutMobile}` : ""
+ }`;
+ const setupMascot = (
+
+ );
+
+ const onDocsViewChange = (value: string | number) => {
+ const mode = value === "table" ? "table" : "card";
+ setViewMode(mode);
+ localStorage.setItem(DOCS_VIEW_STORAGE_KEY, mode);
+ };
+
+ return (
+ } onClick={openSettings}>
+ {t("knowledgeBases.settingsTitle")}
+
+ ) : undefined
+ }
+ >
+ {loading ? (
+
+ ) : showEnableGuide ? (
+
+ ,
+ }
+ : undefined
+ }
+ />
+
+ ) : showEmptyGuide ? (
+
+ ,
+ disabled: atBaseLimit,
+ }}
+ />
+
+ ) : (
+
+ {showListPanel ? (
+
+ ) : null}
+ {!isMobile && !listPanelCollapsed ? (
+
+ ) : null}
+ {showDetailPane ? (
+
+ {!isMobile && listPanelCollapsed ? (
+
+
+
+
+
+ ) : null}
+ {detailLoading ? (
+
+
+
+ ) : null}
+ {!selected && !detailLoading ? (
+
+
+
+ {t("knowledgeBases.selectBase")}
+
+
+ ) : !selected ? null : (
+ <>
+
+
+
+ {isMobile ? (
+
setMobilePane("list")}
+ aria-label={t("knowledgeBases.backToList")}
+ >
+
+
+ ) : null}
+
+ {selected.name}
+
+ {canManageSelected ? (
+
+
+ }
+ aria-label={t("common.edit")}
+ onClick={openEdit}
+ />
+
+
void deleteBase()}
+ >
+
+ }
+ aria-label={t("common.delete")}
+ />
+
+
+
+ ) : null}
+
+
+
+ {selected.description ||
+ t("knowledgeBases.noDescription")}
+
+
+
+
+ {t("knowledgeBases.createdBy", {
+ name: formatKnowledgeOwner(selected),
+ })}
+
+
+
+
+
+
+
+ {t("knowledgeBases.documentLimit", {
+ count: documents.length,
+ max: limits.max_docs_per_kb,
+ })}
+
+
+
+
+ {t("knowledgeBases.viewCard")}
+
+ ),
+ },
+ {
+ value: "table",
+ label: (
+
+
+ {t("knowledgeBases.viewTable")}
+
+ ),
+ },
+ ]}
+ />
+
+ void uploadDocuments(event.target.files)
+ }
+ />
+ {canWriteSelected ? (
+ }
+ disabled={isAtDocumentLimit}
+ onClick={() => uploadRef.current?.click()}
+ >
+ {t("knowledgeBases.upload")}
+
+ ) : null}
+
+
+ {canWriteSelected ? (
+
+ {t("knowledgeBases.uploadHint", {
+ sizeMb: Math.round(
+ limits.max_document_bytes / (1024 * 1024),
+ ),
+ })}
+
+ ) : null}
+ {isAtDocumentLimit ? (
+
+ ) : null}
+ {documents.length === 0 ? (
+
+ ) : showCardView ? (
+
+ {documents.map((document) => (
+
+
+
+
+
+
+ {document.filename}
+
+ {fileExtensionLabel(document.filename) ? (
+
+ {fileExtensionLabel(document.filename)}
+
+ ) : null}
+
+
+ {formatBytes(document.byte_size)}
+ {" · "}
+ {t("knowledgeBases.chunkCount", {
+ count: document.chunk_count,
+ })}
+
+
+ {renderDocumentActions(document)}
+
+
+
+
+ {t(
+ `knowledgeBases.statusesShort.${document.status}`,
+ )}
+
+
+
+ {formatServerDateTime(
+ document.updated_at,
+ timeZone,
+ )}
+
+
+
+ ))}
+
+ ) : (
+
(
+
+
+ {filename}
+ {fileExtensionLabel(filename) ? (
+
+ {fileExtensionLabel(filename)}
+
+ ) : null}
+
+ ),
+ },
+ {
+ title: t("knowledgeBases.status"),
+ key: "status",
+ width: 100,
+ render: (_, document) => (
+
+
+ {t(
+ `knowledgeBases.statusesShort.${document.status}`,
+ )}
+
+
+ ),
+ },
+ {
+ title: t("knowledgeBases.chunks"),
+ dataIndex: "chunk_count",
+ key: "chunk_count",
+ width: 80,
+ },
+ {
+ title: t("knowledgeBases.updatedAt"),
+ dataIndex: "updated_at",
+ key: "updated_at",
+ width: 170,
+ render: (updatedAt: number) =>
+ formatServerDateTime(updatedAt, timeZone),
+ },
+ {
+ title: t("common.actions"),
+ key: "actions",
+ width: canWriteSelected ? 120 : 48,
+ render: (_, document) => (
+
+ {renderDocumentActions(document)}
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+ >
+ )}
+
+ ) : null}
+
+ )}
+
+ setPreviewOpen(false)}
+ footer={null}
+ width={720}
+ destroyOnClose
+ >
+
+ {previewText}
+
+
+
+ setBaseModalOpen(false)}
+ onOk={() => void saveBase()}
+ okText={t(editingBase ? "common.save" : "common.create")}
+ cancelText={t("common.cancel")}
+ width={520}
+ destroyOnClose
+ className={styles.baseModal}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("knowledgeBases.defaultOpen")}
+
+
+ {t("knowledgeBases.defaultOpenHint")}
+
+
+
+
+
+
+
+ {t("knowledgeBases.shared")}
+
+
+ {t("knowledgeBases.sharedHint")}
+
+
+
+
+
+
+
+
+ setFeatureModalOpen(false)}
+ destroyOnHidden
+ footer={
+
+ setFeatureModalOpen(false)}>
+ {t("common.cancel")}
+
+ model.id === featureModel)
+ ?.downloaded) ||
+ featureOptionsLoading ||
+ onnxDownloading
+ : false
+ }
+ onClick={() => void saveFeature()}
+ >
+ {t("common.save")}
+
+
+ }
+ >
+
+
+
+
+ {t("knowledgeBases.settingsOpen")}
+
+
+ {t("knowledgeBases.settingsLead")}
+
+
+
+
+
+
+ {featureEnabledDraft ? (
+
+
+
+ {t("knowledgeBases.selectModel")}
+
+
{
+ setFeatureBackend(event.target.value);
+ setFeatureModel(undefined);
+ }}
+ >
+ {t("knowledgeBases.localOnnx")}
+
+ {t("knowledgeBases.remoteEmbedding")}
+
+
+ {featureBackend === "remote" ? (
+
+
+ ) : (
+
+ {catalog.map((model) => {
+ const selected = featureModel === model.id;
+ const size = formatSizeGb(model.size_gb);
+ const downloading =
+ onnxDownloading && downloadProgressModel === model.id;
+ return (
+
setFeatureModel(model.id)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setFeatureModel(model.id);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+
+ {model.name}
+
+
+ {model.recommended
+ ? t("knowledgeBases.recommended")
+ : null}
+ {model.recommended ? " · " : null}
+ {size
+ ? t("knowledgeBases.approxSize", { size })
+ : t("knowledgeBases.sizeUnknown")}
+ {model.downloaded
+ ? null
+ : ` · ${t("knowledgeBases.notDownloaded")}`}
+
+
+ {model.downloaded ? null : (
+
}
+ loading={downloading}
+ disabled={onnxDownloading && !downloading}
+ onClick={(event) => {
+ event.stopPropagation();
+ setFeatureModel(model.id);
+ void startOnnxDownload(model.id);
+ }}
+ >
+ {t("knowledgeBases.downloadModel")}
+
+ )}
+
+ );
+ })}
+ {catalog.length === 0 ? (
+
+ {t("knowledgeBases.noModels")}
+
+ ) : null}
+ {catalog.length > 0 && !onnxExpanded ? (
+
void loadEmbeddingOptions(true)}
+ >
+ {t("knowledgeBases.showMoreOnnx")}
+
+ ) : null}
+
+ )}
+
+ {t("knowledgeBases.enableDescription")}
+
+
navigate("/admin/models")}>
+ {t("knowledgeBases.manageModels")}
+
+
+
+ ) : null}
+
+
+ {t("models.localDownloadContinueBackground")}
+
+ }
+ >
+
+ {downloadProgressLabel || downloadProgressModel}
+
+
+
+ {t("models.localDownloadBackgroundHint")}
+
+
+
+ );
+}
diff --git a/dashboard/src/pages/KnowledgeBases/knowledgeIcons.tsx b/dashboard/src/pages/KnowledgeBases/knowledgeIcons.tsx
new file mode 100644
index 00000000..de9426dc
--- /dev/null
+++ b/dashboard/src/pages/KnowledgeBases/knowledgeIcons.tsx
@@ -0,0 +1,65 @@
+/**
+ * Curated icons for knowledge bases (humanities, science, tech, …).
+ * Separate from expert icons so the create form stays focused.
+ */
+
+import type { ReactNode } from "react";
+import {
+ Atom,
+ BookOpen,
+ Briefcase,
+ Cpu,
+ FlaskConical,
+ GraduationCap,
+ Landmark,
+ Languages,
+ Layers,
+ Palette,
+ Scale,
+ Terminal,
+ Users,
+ Wrench,
+} from "lucide-react";
+
+export const KNOWLEDGE_ICON_NAMES = [
+ "book-open",
+ "landmark",
+ "users",
+ "scale",
+ "flask-conical",
+ "atom",
+ "wrench",
+ "cpu",
+ "terminal",
+ "graduation-cap",
+ "briefcase",
+ "palette",
+ "languages",
+] as const;
+
+export type KnowledgeIconName = (typeof KNOWLEDGE_ICON_NAMES)[number];
+
+const iconMap: Record ReactNode> = {
+ "book-open": (size) => ,
+ landmark: (size) => ,
+ users: (size) => ,
+ scale: (size) => ,
+ "flask-conical": (size) => ,
+ atom: (size) => ,
+ wrench: (size) => ,
+ cpu: (size) => ,
+ terminal: (size) => ,
+ "graduation-cap": (size) => ,
+ briefcase: (size) => ,
+ palette: (size) => ,
+ languages: (size) => ,
+};
+
+export function knowledgeIconForName(
+ name: string | null | undefined,
+ size = 18,
+): ReactNode {
+ if (!name) return ;
+ const fn = iconMap[name as KnowledgeIconName];
+ return fn ? fn(size) : ;
+}
diff --git a/dashboard/src/pages/Login/OidcComplete.test.tsx b/dashboard/src/pages/Login/OidcComplete.test.tsx
new file mode 100644
index 00000000..0111d0cc
--- /dev/null
+++ b/dashboard/src/pages/Login/OidcComplete.test.tsx
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { readOidcCompleteParams, safeRedirect } from "./OidcComplete";
+
+describe("safeRedirect", () => {
+ it.each([
+ ["//identity.example.com", "/chat"],
+ ["http://identity.example.com", "/chat"],
+ ["/chat://identity.example.com", "/chat"],
+ ["/chat\\identity.example.com", "/chat"],
+ ["chat", "/chat"],
+ ["/agents", "/agents"],
+ ])("allows only internal paths: %s", (redirect, expected) => {
+ expect(safeRedirect(redirect)).toBe(expected);
+ });
+});
+
+describe("readOidcCompleteParams", () => {
+ it("prefers hash over query", () => {
+ expect(
+ readOidcCompleteParams(
+ "#code=from-hash&redirect=%2Fsettings",
+ "?code=from-query",
+ ),
+ ).toEqual({ code: "from-hash", redirect: "/settings" });
+ });
+
+ it("falls back to query for legacy links", () => {
+ expect(readOidcCompleteParams("", "?code=legacy&redirect=%2Fchat")).toEqual(
+ {
+ code: "legacy",
+ redirect: "/chat",
+ },
+ );
+ });
+});
diff --git a/dashboard/src/pages/Login/OidcComplete.tsx b/dashboard/src/pages/Login/OidcComplete.tsx
new file mode 100644
index 00000000..1a6ef3a4
--- /dev/null
+++ b/dashboard/src/pages/Login/OidcComplete.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useRef, useState } from "react";
+import { Button, Result, Spin } from "antd";
+import { message } from "@/utils/antdMessage";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { setAuthToken } from "../../api";
+import { authApi } from "../../api/modules/auth";
+import { refreshServerLabels } from "../../i18n";
+import { apiErrorMessage } from "../../utils/apiError";
+import { applyUserLocale } from "../../utils/locale";
+
+const DEFAULT_REDIRECT = "/chat";
+
+/** Return an internal destination, never an absolute or protocol-relative URL. */
+export function safeRedirect(path: string | null): string {
+ if (
+ !path ||
+ !path.startsWith("/") ||
+ path.startsWith("//") ||
+ path.includes("\\") ||
+ path.includes("://") ||
+ path.startsWith("http:")
+ ) {
+ return DEFAULT_REDIRECT;
+ }
+ return path;
+}
+
+/** Prefer URL fragment (not sent to servers); fall back to query for old links. */
+export function readOidcCompleteParams(
+ hash: string,
+ search: string,
+): { code: string | null; redirect: string | null } {
+ const fromHash = new URLSearchParams(
+ hash.startsWith("#") ? hash.slice(1) : hash,
+ );
+ const fromQuery = new URLSearchParams(
+ search.startsWith("?") ? search.slice(1) : search,
+ );
+ return {
+ code: fromHash.get("code") || fromQuery.get("code"),
+ redirect: fromHash.get("redirect") || fromQuery.get("redirect"),
+ };
+}
+
+export default function OidcComplete() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const did = useRef(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (did.current) return;
+ did.current = true;
+
+ const { code, redirect } = readOidcCompleteParams(
+ window.location.hash,
+ window.location.search,
+ );
+ if (!code) {
+ const text = t("login.oidcComplete.missingCode");
+ setError(text);
+ message.error(text);
+ return;
+ }
+
+ // Drop credentials from the address bar before the network exchange.
+ if (window.location.hash || window.location.search) {
+ window.history.replaceState(null, "", window.location.pathname);
+ }
+
+ void authApi
+ .exchangeOidcCode(code)
+ .then(async (res) => {
+ setAuthToken(res.access_token);
+ await applyUserLocale(res.user.locale);
+ void refreshServerLabels(res.user.locale);
+ navigate(safeRedirect(redirect), { replace: true });
+ })
+ .catch((err) => {
+ const text = apiErrorMessage(err, t("login.oidcComplete.failed"), t);
+ setError(text);
+ message.error(text);
+ });
+ }, [navigate, t]);
+
+ if (error) {
+ return (
+ navigate("/login", { replace: true })}
+ >
+ {t("login.oidcComplete.backToLogin")}
+
+ }
+ />
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/dashboard/src/pages/Login/index.tsx b/dashboard/src/pages/Login/index.tsx
index ba922f65..2ab7c63f 100644
--- a/dashboard/src/pages/Login/index.tsx
+++ b/dashboard/src/pages/Login/index.tsx
@@ -1,12 +1,12 @@
import { useState, useEffect } from "react";
-import { useNavigate } from "react-router-dom";
+import { useNavigate, useSearchParams } from "react-router-dom";
import { Input, Button } from "antd";
import { message } from "@/utils/antdMessage";
import { Lock, User } from "lucide-react";
import { useTranslation } from "react-i18next";
import { setAuthToken } from "../../api";
-import { authApi } from "../../api/modules/auth";
+import { authApi, type OidcStatus } from "../../api/modules/auth";
import { apiErrorMessage } from "../../utils/apiError";
import { refreshServerLabels } from "../../i18n";
import { applyUserLocale, applyGuestLocale } from "../../utils/locale";
@@ -17,9 +17,12 @@ export default function LoginPage() {
const { t } = useTranslation();
const { isDark } = useTheme();
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
+ const [oidc, setOidc] = useState(null);
+ const [oidcLoading, setOidcLoading] = useState(false);
const [slideVerified, setSlideVerified] = useState(false);
const [slideResetKey, setSlideResetKey] = useState(0);
@@ -48,11 +51,40 @@ export default function LoginPage() {
};
}, [navigate]);
+ useEffect(() => {
+ authApi
+ .getOidcStatus()
+ .then(setOidc)
+ .catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ const code = searchParams.get("oidc_error");
+ if (!code) return;
+ message.error(
+ t(`login.oidcError.${code}`, {
+ defaultValue: t("login.oidcError.generic"),
+ }),
+ );
+ navigate("/login", { replace: true });
+ }, [navigate, searchParams, t]);
+
const resetSlide = () => {
setSlideVerified(false);
setSlideResetKey((k) => k + 1);
};
+ const onOidc = async () => {
+ setOidcLoading(true);
+ try {
+ const { authorization_url } = await authApi.startOidc("/chat");
+ window.location.href = authorization_url;
+ } catch (err) {
+ message.error(apiErrorMessage(err, t("login.oidcStartFailed"), t));
+ setOidcLoading(false);
+ }
+ };
+
const handleLogin = async () => {
if (!username || !password || !slideVerified) return;
setLoading(true);
@@ -163,6 +195,46 @@ export default function LoginPage() {
>
{t("login.submit")}
+
+ {oidc?.enabled && (
+ <>
+
+
+ {t("login.or")}
+
+
+
+ {t("login.oidcWith", { name: oidc.display_name })}
+
+ >
+ )}
);
diff --git a/dashboard/src/pages/Settings/AdvancedSettings/index.tsx b/dashboard/src/pages/Settings/AdvancedSettings/index.tsx
index 721cf47a..9cfd1fba 100644
--- a/dashboard/src/pages/Settings/AdvancedSettings/index.tsx
+++ b/dashboard/src/pages/Settings/AdvancedSettings/index.tsx
@@ -1,5 +1,4 @@
-import { useState, useEffect, type ReactNode } from "react";
-import { useSearchParams } from "react-router-dom";
+import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Archive,
@@ -20,6 +19,9 @@ import UpdateConfig from "./UpdateConfig";
import PageShell from "../../../layouts/PageShell";
import SettingsTabBar from "../shared/SettingsTabBar";
import tabStyles from "./tabContent.module.less";
+import ForbiddenPage from "../../../components/ForbiddenPage";
+import { useGatedSearchTabs } from "../../../hooks/useGatedSearchTabs";
+import { ADVANCED_TAB_PERMISSIONS } from "../../../utils/permissions";
type TabKey =
| "env-vars"
@@ -68,24 +70,14 @@ function parseTab(raw: string | null): TabKey {
export default function AdvancedSettingsPage() {
const { t } = useTranslation();
- const [searchParams, setSearchParams] = useSearchParams();
- const [activeTab, setActiveTab] = useState(() =>
- parseTab(searchParams.get("tab")),
- );
-
- useEffect(() => {
- setActiveTab(parseTab(searchParams.get("tab")));
- }, [searchParams]);
+ const { allowedTabs, activeTab, forbidden, selectTab } = useGatedSearchTabs({
+ tabs: TABS,
+ tabPermissions: ADVANCED_TAB_PERMISSIONS,
+ parseTab,
+ querylessKey: "env-vars",
+ });
- const selectTab = (key: TabKey) => {
- setActiveTab(key);
- if (key === "env-vars") {
- searchParams.delete("tab");
- setSearchParams(searchParams, { replace: true });
- } else {
- setSearchParams({ tab: key }, { replace: true });
- }
- };
+ if (forbidden) return ;
const renderTab = () => {
switch (activeTab) {
@@ -112,7 +104,7 @@ export default function AdvancedSettingsPage() {
subtitle={t("pageShell.adminAdvanced.subtitle")}
tabBar={
diff --git a/dashboard/src/pages/Settings/BackupRestore/index.module.less b/dashboard/src/pages/Settings/BackupRestore/index.module.less
index 68efc69e..10bdf009 100644
--- a/dashboard/src/pages/Settings/BackupRestore/index.module.less
+++ b/dashboard/src/pages/Settings/BackupRestore/index.module.less
@@ -2,6 +2,56 @@
margin-bottom: 28px;
}
+.autoForm {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ max-width: 560px;
+}
+
+.autoRow {
+ display: grid;
+ grid-template-columns: minmax(140px, 220px) minmax(0, 1fr);
+ gap: 12px;
+ align-items: center;
+ margin: 0;
+ font-size: 13px;
+ color: var(--fn-text-primary);
+
+ @media (max-width: 767px) {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+}
+
+.autoControl {
+ width: 100%;
+ max-width: 320px;
+}
+
+.autoSwitch {
+ justify-self: start;
+}
+
+.autoControlStack {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-width: 320px;
+ width: 100%;
+}
+
+.autoHint {
+ font-size: 12px;
+ color: var(--fn-text-secondary);
+ line-height: 1.4;
+}
+
+.autoStatus {
+ font-size: 12px;
+ color: var(--fn-text-secondary);
+}
+
.actions {
display: flex;
flex-wrap: wrap;
diff --git a/dashboard/src/pages/Settings/BackupRestore/index.tsx b/dashboard/src/pages/Settings/BackupRestore/index.tsx
index 2568bc2c..69258f17 100644
--- a/dashboard/src/pages/Settings/BackupRestore/index.tsx
+++ b/dashboard/src/pages/Settings/BackupRestore/index.tsx
@@ -2,10 +2,15 @@ import { useCallback, useEffect, useState } from "react";
import {
Button,
Checkbox,
+ Divider,
Empty,
+ Input,
+ InputNumber,
Modal,
Progress,
+ Select,
Spin,
+ Switch,
Table,
Upload,
} from "antd";
@@ -14,6 +19,7 @@ import { message } from "@/utils/antdMessage";
import type { ColumnsType } from "antd/es/table";
import {
Archive,
+ CalendarClock,
Download,
Plus,
RefreshCw,
@@ -23,7 +29,11 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
-import { backupApi, type BackupFileItem } from "../../../api/modules/backup";
+import {
+ backupApi,
+ type AutoBackupSettings,
+ type BackupFileItem,
+} from "../../../api/modules/backup";
import { useServiceRestartContext } from "../../../context/ServiceRestartContext";
import { useIsMobile } from "../../../hooks/useIsMobile";
import { useServerTimezone } from "../../../hooks/useServerTimezone";
@@ -32,6 +42,16 @@ import { formatServerIsoDateTime } from "../../../utils/formatMessageTime";
import { TabPanelHeader } from "../AdvancedSettings/TabPanelHeader";
import styles from "./index.module.less";
+const SCHEDULE_DAILY = "cron:0 4 * * *";
+const SCHEDULE_WEEKLY = "cron:0 4 * * 0";
+const SCHEDULE_12H = "interval:43200";
+
+const PRESET_SCHEDULES = new Set([
+ SCHEDULE_DAILY,
+ SCHEDULE_WEEKLY,
+ SCHEDULE_12H,
+]);
+
function triggerDownload(blob: Blob, filename: string) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
@@ -46,6 +66,12 @@ function formatSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
+/** Seconds when the spec is `interval:`, otherwise null. */
+function parseIntervalSeconds(spec: string): number | null {
+ const matched = /^interval:(\d+)$/.exec(spec.trim());
+ return matched ? Number(matched[1]) : null;
+}
+
interface BackupFileCardProps {
row: BackupFileItem;
downloading: boolean;
@@ -134,7 +160,34 @@ export default function BackupRestorePanel() {
);
const [downloading, setDownloading] = useState(null);
- const busy = creating || restoring || uploadPercent !== null || isRestarting;
+ const [autoEnabled, setAutoEnabled] = useState(false);
+ const [autoSchedule, setAutoSchedule] = useState(SCHEDULE_DAILY);
+ const [schedulePreset, setSchedulePreset] = useState(SCHEDULE_DAILY);
+ const [autoRetention, setAutoRetention] = useState(7);
+ const [autoScheduled, setAutoScheduled] = useState(false);
+ const [autoLoading, setAutoLoading] = useState(false);
+ const [autoSaving, setAutoSaving] = useState(false);
+ const [autoRunning, setAutoRunning] = useState(false);
+
+ const busy =
+ creating ||
+ restoring ||
+ uploadPercent !== null ||
+ isRestarting ||
+ autoSaving ||
+ autoRunning;
+
+ const customIntervalSeconds = parseIntervalSeconds(autoSchedule);
+
+ const applyAutoSettings = useCallback((data: AutoBackupSettings) => {
+ setAutoEnabled(data.auto_enabled);
+ setAutoSchedule(data.schedule);
+ setAutoRetention(data.retention_count);
+ setAutoScheduled(Boolean(data.scheduled));
+ setSchedulePreset(
+ PRESET_SCHEDULES.has(data.schedule) ? data.schedule : "custom",
+ );
+ }, []);
const refresh = useCallback(async () => {
setLoading(true);
@@ -150,9 +203,22 @@ export default function BackupRestorePanel() {
}
}, [t]);
+ const refreshAuto = useCallback(async () => {
+ setAutoLoading(true);
+ try {
+ const data = await backupApi.getAutoSettings();
+ applyAutoSettings(data);
+ } catch (err: unknown) {
+ message.error(apiErrorMessage(err, t("backup.autoLoadFailed"), t));
+ } finally {
+ setAutoLoading(false);
+ }
+ }, [applyAutoSettings, t]);
+
useEffect(() => {
void refresh();
- }, [refresh]);
+ void refreshAuto();
+ }, [refresh, refreshAuto]);
const onCreate = async () => {
setCreating(true);
@@ -161,13 +227,42 @@ export default function BackupRestorePanel() {
message.success(t("backup.createSuccess"));
await refresh();
} catch (err: unknown) {
- const detail = err instanceof Error ? err.message : String(err);
- message.error(detail || t("backup.createFailed"));
+ message.error(apiErrorMessage(err, t("backup.createFailed"), t));
} finally {
setCreating(false);
}
};
+ const onSaveAuto = async () => {
+ setAutoSaving(true);
+ try {
+ const data = await backupApi.updateAutoSettings({
+ auto_enabled: autoEnabled,
+ schedule: autoSchedule.trim() || SCHEDULE_DAILY,
+ retention_count: autoRetention,
+ });
+ applyAutoSettings(data);
+ message.success(t("backup.autoSaveSuccess"));
+ } catch (err: unknown) {
+ message.error(apiErrorMessage(err, t("backup.autoSaveFailed"), t));
+ } finally {
+ setAutoSaving(false);
+ }
+ };
+
+ const onRunAuto = async () => {
+ setAutoRunning(true);
+ try {
+ await backupApi.runAutoBackup();
+ message.success(t("backup.autoRunSuccess"));
+ await refresh();
+ } catch (err: unknown) {
+ message.error(apiErrorMessage(err, t("backup.autoRunFailed"), t));
+ } finally {
+ setAutoRunning(false);
+ }
+ };
+
const onDownload = async (row: BackupFileItem) => {
setDownloading(row.name);
try {
@@ -432,6 +527,120 @@ export default function BackupRestorePanel() {
+
+
+ }
+ title={t("backup.autoTitle")}
+ description={t("backup.autoDesc")}
+ />
+
+
+
+
+
+ {schedulePreset === "custom" ? (
+
+ ) : null}
+
+
+ {autoScheduled
+ ? t("backup.autoScheduled")
+ : t("backup.autoNotScheduled")}
+
+
+ void onSaveAuto()}
+ >
+ {t("backup.autoSave")}
+
+ void onRunAuto()}
+ >
+ {t("backup.autoRunNow")}
+
+
+
+
+
+
void | Promise;
+ isHover: boolean;
+ onMouseEnter: () => void;
+ onMouseLeave: () => void;
+}
+
+function presetModelsToRows(preset: ProviderPreset): ProviderModel[] {
+ return preset.models.map((m) => ({
+ id: m.id,
+ name: m.name,
+ enabled: false,
+ embedding: preset.id === "onnx" ? true : undefined,
+ task: preset.id === "onnx" ? "embedding" : undefined,
+ input: m.input?.length ? m.input : ["text"],
+ thinking: null,
+ }));
+}
+
+export function LocalServiceCard({
+ preset,
+ provider,
+ onSaved,
+ isHover,
+ onMouseEnter,
+ onMouseLeave,
+}: LocalServiceCardProps) {
+ const { t } = useTranslation();
+ const isOnnx = preset.id === "onnx";
+ const [serviceEnabled, setServiceEnabled] = useState(false);
+ const [serviceRunning, setServiceRunning] = useState(false);
+ const [serviceBusy, setServiceBusy] = useState(false);
+ const [depsAvailable, setDepsAvailable] = useState(true);
+ const [depsInstallFailed, setDepsInstallFailed] = useState(false);
+ const [ensuring, setEnsuring] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [row, setRow] = useState(provider);
+
+ useEffect(() => {
+ setRow(provider);
+ }, [provider]);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ if (isOnnx) {
+ const st = await onnxModelApi.getStatus();
+ if (!cancelled) {
+ setServiceEnabled(st.enabled);
+ setServiceRunning(st.ready || st.enabled);
+ setDepsAvailable(st.deps_available !== false);
+ setDepsInstallFailed(false);
+ }
+ } else {
+ const st = await ollamaModelApi.getService();
+ if (!cancelled) {
+ setServiceEnabled(st.enabled);
+ setServiceRunning(st.running);
+ setDepsAvailable(true);
+ setDepsInstallFailed(false);
+ }
+ }
+ } catch {
+ if (!cancelled) {
+ setServiceEnabled(false);
+ setServiceRunning(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [isOnnx, provider?.id]);
+
+ const ensureProvider = async (): Promise => {
+ if (row) return row;
+ setEnsuring(true);
+ try {
+ const created = await request("/admin/providers", {
+ method: "POST",
+ body: JSON.stringify({
+ name: preset.name,
+ kind: preset.protocol,
+ base_url: preset.base_url || null,
+ api_key: preset.id,
+ models: presetModelsToRows(preset),
+ enabled: false,
+ }),
+ });
+ setRow(created);
+ await onSaved();
+ return created;
+ } finally {
+ setEnsuring(false);
+ }
+ };
+
+ const handleServiceToggle = async (next: boolean) => {
+ setServiceBusy(true);
+ try {
+ const ensured = await ensureProvider();
+ if (isOnnx) {
+ const st = await onnxModelApi.setService(next);
+ setServiceEnabled(st.enabled);
+ setServiceRunning(st.ready || st.enabled);
+ setDepsAvailable(st.deps_available !== false);
+ setDepsInstallFailed(false);
+ } else {
+ const st = await ollamaModelApi.setService(next);
+ setServiceEnabled(st.enabled);
+ setServiceRunning(st.running);
+ }
+ await request(`/admin/providers/${ensured.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({ enabled: next }),
+ });
+ await onSaved();
+ message.success(
+ next
+ ? t("models.localServiceStarted")
+ : t("models.localServiceStopped"),
+ );
+ } catch (err) {
+ if (isOnnx && next) {
+ setDepsInstallFailed(true);
+ setDepsAvailable(false);
+ }
+ message.error(
+ err instanceof Error
+ ? err.message
+ : t("models.localServiceToggleFailed"),
+ );
+ } finally {
+ setServiceBusy(false);
+ }
+ };
+
+ const handleOpenSettings = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ try {
+ await ensureProvider();
+ setModalOpen(true);
+ } catch (err) {
+ message.error(
+ err instanceof Error ? err.message : t("models.createFailedSimple"),
+ );
+ }
+ };
+
+ const logo = getProviderLogo(presetLogoId(preset)) ?? customProviderLogo;
+ const models = row?.models ?? preset.models;
+ const baseUrl = row?.base_url ?? preset.base_url;
+
+ return (
+ <>
+
+
+
+
+ {logo && (
+
+ )}
+ {preset.name}
+
+
+
+
+ {serviceEnabled
+ ? serviceRunning
+ ? t("models.localServiceRunning")
+ : t("models.localServiceOn")
+ : t("models.localServiceOff")}
+
+
+
+
+
+ {!isOnnx && (
+
+ Base URL:
+ {baseUrl ? (
+
+ {baseUrl}
+
+ ) : (
+
+ {t("models.localRuntime")}
+
+ )}
+
+ )}
+
+ {t("models.model")}:
+
+ {models.length > 0
+ ? t("models.modelsCount", { count: models.length })
+ : t("models.noModels")}
+
+
+ {isOnnx && !depsAvailable && (
+
+
+ {depsInstallFailed
+ ? t("models.onnxDepsInstallFailed")
+ : t("models.onnxDepsPending")}
+
+
+ )}
+
+
+
+
+
+
+ void handleServiceToggle(c)}
+ onClick={(_, e) => e.stopPropagation()}
+ />
+
+
+ {t("models.localServiceLabel")}
+
+
+
+
+ void handleOpenSettings(e)}
+ className={styles.cardActionBtn}
+ icon={}
+ />
+
+
+
+
+
+ {row && (
+ setModalOpen(false)}
+ onSaved={async () => {
+ await onSaved();
+ }}
+ apiPrefix="/admin/providers"
+ />
+ )}
+ >
+ );
+}
diff --git a/dashboard/src/pages/Settings/Models/components/cards/PresetProviderCard.tsx b/dashboard/src/pages/Settings/Models/components/cards/PresetProviderCard.tsx
index f0f8e22f..00616255 100644
--- a/dashboard/src/pages/Settings/Models/components/cards/PresetProviderCard.tsx
+++ b/dashboard/src/pages/Settings/Models/components/cards/PresetProviderCard.tsx
@@ -1,18 +1,22 @@
/**
* PresetProviderCard — card for a built-in preset provider.
*
- * Two states:
- * - Not configured: gray card with logo, name, protocol badge, and status label
- * → click opens PresetProviderModal to create the provider
- * - Configured: renders the existing ProviderCard for the matching ProviderRow
- * → click opens ProviderConfigModal to edit
+ * Local runtimes (Ollama / ONNX): always LocalServiceCard — service switch, no create CTA.
+ * Cloud presets:
+ * - Not configured → gray card; click opens PresetProviderModal
+ * - Configured → ProviderCard
*/
import { useState } from "react";
import { Card, Tag } from "antd";
import { useTranslation } from "react-i18next";
import type { ProviderRow, ProviderPreset } from "../../useProviders";
-import { findConfiguredProvider, presetLogoId } from "../../presetUtils";
+import {
+ findConfiguredProvider,
+ isLocalPreset,
+ presetLogoId,
+} from "../../presetUtils";
import { ProviderCard } from "./ProviderCard";
+import { LocalServiceCard } from "./LocalServiceCard";
import { PresetProviderModal } from "../modals/PresetProviderModal";
import {
getProviderLogo,
@@ -41,12 +45,22 @@ export function PresetProviderCard({
const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
- // Find an existing provider with matching name
const configured = findConfiguredProvider(preset, providers);
-
const logo = getProviderLogo(presetLogoId(preset)) ?? customProviderLogo;
- // If already configured, render the real ProviderCard
+ if (isLocalPreset(preset)) {
+ return (
+
+ );
+ }
+
if (configured) {
return (
0;
const statusReady = hasApiKey;
@@ -145,6 +155,64 @@ export function ProviderCard({
customProviderLogo;
const models = provider.models ?? [];
+ useEffect(() => {
+ if (!isLocalRuntime) return;
+ let cancelled = false;
+ void (async () => {
+ try {
+ if (isOllama) {
+ const st = await ollamaModelApi.getService();
+ if (!cancelled) {
+ setServiceEnabled(st.enabled);
+ setServiceRunning(st.running);
+ }
+ } else if (isOnnx) {
+ const st = await onnxModelApi.getStatus();
+ if (!cancelled) {
+ setServiceEnabled(st.enabled);
+ setServiceRunning(st.ready || st.enabled);
+ }
+ }
+ } catch {
+ if (!cancelled) {
+ setServiceEnabled(false);
+ setServiceRunning(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [isLocalRuntime, isOllama, isOnnx, provider.id]);
+
+ const handleServiceToggle = async (next: boolean) => {
+ setServiceBusy(true);
+ try {
+ if (isOllama) {
+ const st = await ollamaModelApi.setService(next);
+ setServiceEnabled(st.enabled);
+ setServiceRunning(st.running);
+ } else if (isOnnx) {
+ const st = await onnxModelApi.setService(next);
+ setServiceEnabled(st.enabled);
+ setServiceRunning(st.ready || st.enabled);
+ }
+ message.success(
+ next
+ ? t("models.localServiceStarted")
+ : t("models.localServiceStopped"),
+ );
+ } catch (err) {
+ message.error(
+ err instanceof Error
+ ? err.message
+ : t("models.localServiceToggleFailed"),
+ );
+ } finally {
+ setServiceBusy(false);
+ }
+ };
+
return (
<>
+ {isLocalRuntime && (
+ <>
+
+ void handleServiceToggle(c)}
+ onClick={(_, e) => e.stopPropagation()}
+ />
+
+
+ {serviceEnabled
+ ? serviceRunning
+ ? t("models.localServiceRunning")
+ : t("models.localServiceOn")
+ : t("models.localServiceOff")}
+
+
+ >
+ )}
- {hasApiKey && (
+ {hasApiKey && !isOnnx && (
}
/>
-
- }
- />
-
+ {isLocalRuntime ? null : (
+
+ }
+ />
+
+ )}
diff --git a/dashboard/src/pages/Settings/Models/components/cards/index.ts b/dashboard/src/pages/Settings/Models/components/cards/index.ts
index 41480be1..4c176fb2 100644
--- a/dashboard/src/pages/Settings/Models/components/cards/index.ts
+++ b/dashboard/src/pages/Settings/Models/components/cards/index.ts
@@ -1,3 +1,4 @@
export * from "./ProviderCard";
export * from "./PresetProviderCard";
export * from "./PresetGroupCard";
+export * from "./LocalServiceCard";
diff --git a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx
index aee061e5..439db47b 100644
--- a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx
+++ b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx
@@ -9,6 +9,7 @@ import { Download, Zap } from "lucide-react";
import { useTranslation } from "react-i18next";
import { request } from "../../../../../api/request";
import type { ProviderModel, ProviderRow } from "../../useProviders";
+import { isEmbeddingModel } from "../../useProviders";
import { fetchProviderModels, testProviderDraft } from "../../providerApi";
import { ModelListEditor } from "./ModelListEditor";
import styles from "../../index.module.less";
@@ -88,6 +89,7 @@ export function CustomProviderModal({
api_key: key,
base_url: values.base_url?.trim() || null,
model_id: modelId,
+ embedding: isEmbeddingModel(models.find((m) => m.id === modelId)),
});
};
@@ -164,6 +166,10 @@ export function CustomProviderModal({
if (m.max_tokens != null) entry.max_tokens = m.max_tokens;
if (m.context_window != null) entry.context_window = m.context_window;
if (m.reasoning) entry.reasoning = true;
+ if (isEmbeddingModel(m)) {
+ entry.embedding = true;
+ entry.task = "embedding";
+ }
return entry;
});
diff --git a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx
index 4d692706..338790ac 100644
--- a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx
+++ b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx
@@ -5,13 +5,23 @@
* Connectivity tests still hit the live provider endpoint.
*/
import { useState } from "react";
-import { Button, Form, Input, InputNumber, Modal, Select, Switch } from "antd";
+import {
+ Button,
+ Form,
+ Input,
+ InputNumber,
+ Modal,
+ Select,
+ Switch,
+ Tooltip,
+} from "antd";
import { message } from "@/utils/antdMessage";
import {
Check,
ChevronDown,
ChevronUp,
+ Download,
Pencil,
Plus,
Trash2,
@@ -21,9 +31,21 @@ import {
import { useTranslation } from "react-i18next";
import { request } from "../../../../../api/request";
import type { ProviderRow, ProviderModel } from "../../useProviders";
+import { isEmbeddingModel } from "../../useProviders";
+import { isOnnxProviderRow } from "../../presetUtils";
import { ModelMetaTags } from "../../modelMeta";
import styles from "../../index.module.less";
+export interface LocalModelDownloadControl {
+ /** Model ids already present on disk / in the local runtime. */
+ downloadedIds: ReadonlySet | readonly string[];
+ /** Model ids currently downloading. */
+ downloadingIds?: ReadonlySet | readonly string[];
+ onDownload: (modelId: string) => void;
+ /** When true, enable switch is locked until the model is downloaded. */
+ requireDownloadToEnable?: boolean;
+}
+
interface ModelListEditorProps {
provider: ProviderRow;
models: ProviderModel[];
@@ -31,6 +53,7 @@ interface ModelListEditorProps {
/** API path prefix for test. Defaults to "/providers". */
apiPrefix?: string;
canTest?: boolean;
+ localDownload?: LocalModelDownloadControl;
onTestModel?: (
modelId: string,
modelName: string,
@@ -49,9 +72,21 @@ export function ModelListEditor({
onModelsChange,
apiPrefix = "/providers",
canTest,
+ localDownload,
onTestModel,
}: ModelListEditorProps) {
const { t } = useTranslation();
+ const downloadedSet = (() => {
+ if (!localDownload) return null;
+ const raw = localDownload.downloadedIds;
+ return raw instanceof Set ? raw : new Set(raw);
+ })();
+ const downloadingSet = (() => {
+ if (!localDownload?.downloadingIds) return new Set();
+ const raw = localDownload.downloadingIds;
+ return raw instanceof Set ? raw : new Set(raw);
+ })();
+ const requireDownload = !!localDownload?.requireDownloadToEnable;
const [adding, setAdding] = useState(false);
const [editingModelId, setEditingModelId] = useState(null);
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -61,12 +96,23 @@ export function ModelListEditor({
>(new Map());
const [testingForm, setTestingForm] = useState(false);
const [form] = Form.useForm();
+ const isOnnx = isOnnxProviderRow(provider);
+ const embeddingOn = isOnnx || Form.useWatch("embedding", form) === true;
const handleToggleEnabled = (
modelId: string,
_modelName: string,
enabled: boolean,
) => {
+ if (
+ enabled &&
+ requireDownload &&
+ downloadedSet &&
+ !downloadedSet.has(modelId)
+ ) {
+ message.warning(t("models.downloadBeforeEnable"));
+ return;
+ }
onModelsChange(
models.map((m) => (m.id === modelId ? { ...m, enabled } : m)),
);
@@ -93,17 +139,21 @@ export function ModelListEditor({
const runTest = async (
modelId: string,
modelName: string,
+ embedding?: boolean,
): Promise<{ ok: boolean; latency_ms?: number; error?: string }> => {
if (onTestModel) {
return onTestModel(modelId, modelName);
}
+ const isEmbedding =
+ embedding ??
+ (isOnnx || isEmbeddingModel(models.find((m) => m.id === modelId)));
return request<{
ok: boolean;
latency_ms?: number;
error?: string;
}>(`${apiPrefix}/${provider.id}/test`, {
method: "POST",
- body: JSON.stringify({ model_id: modelId }),
+ body: JSON.stringify({ model_id: modelId, embedding: isEmbedding }),
});
};
@@ -157,7 +207,7 @@ export function ModelListEditor({
}
setTestingForm(true);
try {
- const result = await runTest(modelId, modelId);
+ const result = await runTest(modelId, modelId, embeddingOn);
const modelName =
(form.getFieldValue("name") as string | undefined)?.trim() || modelId;
if (result.ok) {
@@ -182,13 +232,23 @@ export function ModelListEditor({
const buildModelEntry = (values: Record): ProviderModel => {
const id = (values.id as string).trim();
const name = (values.name as string | undefined)?.trim() || id;
+ const isOnnx = isOnnxProviderRow(provider);
+ const embedding = isOnnx || values.embedding === true;
const entry: ProviderModel = {
id,
name,
enabled: true,
- input: (values.input as string[] | undefined) || ["text"],
+ input: ["text"],
thinking: null,
};
+ if (embedding) {
+ entry.embedding = true;
+ entry.task = "embedding";
+ return entry;
+ }
+ if (values.input != null) {
+ entry.input = (values.input as string[] | undefined) || ["text"];
+ }
if (values.context_window != null)
entry.context_window = values.context_window as number;
if (values.max_tokens != null)
@@ -230,6 +290,9 @@ export function ModelListEditor({
message.error(t("models.initialModelDuplicate", { name: entry.id }));
return;
}
+ if (requireDownload) {
+ entry.enabled = false;
+ }
onModelsChange([...models, entry]);
message.success(t("models.modelAdded", { name: entry.name }));
resetForm();
@@ -273,6 +336,7 @@ export function ModelListEditor({
reasoning_effort_type: model.reasoning_config?.effort_type ?? "enum",
reasoning_adapter: model.reasoning_config?.adapter ?? "thinking",
input: model.input ?? ["text"],
+ embedding: Boolean(model.embedding || model.task === "embedding"),
});
const hasAdvanced =
(model as Record).context_window != null ||
@@ -309,6 +373,9 @@ export function ModelListEditor({
const isCurrentEditing = editingModelId === m.id;
const isEnabled = m.enabled !== false;
const isTesting = testingIds.has(m.id);
+ const isDownloaded = downloadedSet ? downloadedSet.has(m.id) : true;
+ const isDownloading = downloadingSet.has(m.id);
+ const enableLocked = requireDownload && !isDownloaded;
return (
-
- handleToggleEnabled(m.id, m.name, checked)
+
+ >
+
+ handleToggleEnabled(m.id, m.name, checked)
+ }
+ className={styles.modelToggle}
+ />
+
{m.name}
{m.name !== m.id && (
{m.id}
)}
-
+ {localDownload && (
+
+ {isDownloaded
+ ? t("models.localModelDownloaded")
+ : t("models.notDownloaded")}
+
+ )}
+ {(m.embedding || m.task === "embedding") && (
+
+ {t("models.embeddingOnlyTag")}
+
+ )}
+ {m.embedding || m.task === "embedding" ? null : (
+
+ )}
+ {localDownload && !isDownloaded && (
+
}
+ loading={isDownloading}
+ onClick={() => localDownload.onDownload(m.id)}
+ title={t("models.localDownloadModel")}
+ style={{ marginRight: 4 }}
+ />
+ )}
{hasApiKey &&
(isTesting ? (
-
-
-
-
-
- ) : (
-
- )
- }
- onClick={() => setShowAdvanced(!showAdvanced)}
+ {isOnnx ? null : (
+
- {showAdvanced
- ? t("models.hideAdvanced")
- : t("models.showAdvanced")}
-
-
+
+
+ )}
- {showAdvanced && (
-
-
-
-
-
-
-
-
-
+ {embeddingOn ? null : (
+ <>
-
-
-
- {({ getFieldValue }) =>
- getFieldValue("reasoning") ? (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : null
- }
+
-
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={() => setShowAdvanced(!showAdvanced)}
+ >
+ {showAdvanced
+ ? t("models.hideAdvanced")
+ : t("models.showAdvanced")}
+
+
+
+ {showAdvanced && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {({ getFieldValue }) =>
+ getFieldValue("reasoning") ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null
+ }
+
+
+ )}
+ >
)}
([]);
const [form] = Form.useForm
();
- const isOllama = preset.id === "ollama";
const isCodexOAuth = preset.auth_method === "codex_oauth";
const apiKey = Form.useWatch("api_key", form) as string | undefined;
- const canTest = !!(apiKey?.trim() || isOllama);
+ const canTest = !!apiKey?.trim();
const draftProvider = useMemo(
() => ({
@@ -58,21 +59,12 @@ export function PresetProviderModal({
base_url:
(form.getFieldValue("base_url") as string | undefined) ||
preset.base_url,
- api_key:
- (form.getFieldValue("api_key") as string | undefined) ||
- (isOllama ? "ollama" : null),
+ api_key: (form.getFieldValue("api_key") as string | undefined) || null,
models: draftModels,
note: null,
enabled: true,
}),
- [
- draftModels,
- form,
- isOllama,
- preset.base_url,
- preset.name,
- preset.protocol,
- ],
+ [draftModels, form, preset.base_url, preset.name, preset.protocol],
);
useEffect(() => {
@@ -107,15 +99,15 @@ export function PresetProviderModal({
if (isCodexOAuth) return;
try {
const values = await form.validateFields(["base_url", "api_key"]);
- const apiKey = values.api_key?.trim() || (isOllama ? "ollama" : "");
- if (!apiKey) {
+ const key = values.api_key?.trim();
+ if (!key) {
message.warning(t("models.pleaseEnterApiKey"));
return;
}
setFetchingModels(true);
const result = await fetchProviderModels({
kind: "openai",
- api_key: apiKey,
+ api_key: key,
base_url: values.base_url?.trim() || preset.base_url,
});
if (!result.ok) {
@@ -161,7 +153,7 @@ export function PresetProviderModal({
const testDraftModel = async (modelId: string) => {
const values = await form.validateFields(["name", "base_url", "api_key"]);
- const key = values.api_key?.trim() || (isOllama ? "ollama" : "");
+ const key = values.api_key?.trim();
if (!key) {
return { ok: false, error: t("models.pleaseEnterApiKey") };
}
@@ -171,6 +163,7 @@ export function PresetProviderModal({
api_key: key,
base_url: values.base_url?.trim() || preset.base_url,
model_id: modelId,
+ embedding: isEmbeddingModel(draftModels.find((m) => m.id === modelId)),
});
};
@@ -184,8 +177,8 @@ export function PresetProviderModal({
message.warning(t("models.testDraftNeedModel"));
return;
}
- const apiKey = values.api_key?.trim() || (isOllama ? "ollama" : "");
- if (!apiKey) {
+ const key = values.api_key?.trim();
+ if (!key) {
message.warning(t("models.pleaseEnterApiKey"));
return;
}
@@ -227,7 +220,7 @@ export function PresetProviderModal({
name: values.name.trim(),
kind: preset.protocol,
base_url: values.base_url?.trim() || null,
- api_key: values.api_key?.trim() || (isOllama ? "ollama" : null),
+ api_key: values.api_key?.trim() || null,
models: draftModels,
}),
});
@@ -318,17 +311,15 @@ export function PresetProviderModal({
diff --git a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx
index a4b7845b..9cde7395 100644
--- a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx
+++ b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx
@@ -7,15 +7,23 @@
* with local model list, download, and delete UI
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Button, Divider, Form, Input, Modal, Select } from "antd";
+import { Button, Divider, Form, Input, Modal, Progress, Select } from "antd";
import { message } from "@/utils/antdMessage";
import { Download, Key, Loader2, Trash2, X, Zap } from "lucide-react";
import { useTranslation } from "react-i18next";
import { request } from "../../../../../api/request";
import type { ProviderRow, ProviderModel } from "../../useProviders";
+import { isEmbeddingModel } from "../../useProviders";
import { fetchProviderModels, testProviderDraft } from "../../providerApi";
import { getProviderDocs } from "../../../../../assets/providers";
+import { ollamaModelApi } from "../../../../../api/modules/ollamaModel";
+import { onnxModelApi } from "../../../../../api/modules/onnxModel";
+import {
+ setOnnxDownloadProgressHandler,
+ watchOnnxDownload,
+} from "../../../../../api/modules/onnxDownloadWatcher";
+import { isOllamaProviderRow, isOnnxProviderRow } from "../../presetUtils";
import { ModelListEditor } from "./ModelListEditor";
import styles from "../../index.module.less";
@@ -60,15 +68,6 @@ function formatFileSize(bytes: number): string {
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
-function isOllamaProvider(provider: ProviderRow): boolean {
- return (
- provider.name === "ollama" ||
- provider.name === "Ollama (Local)" ||
- (provider.base_url?.includes("11434") ?? false) ||
- (provider.base_url?.includes("ollama") ?? false)
- );
-}
-
export function ProviderConfigModal({
provider,
open,
@@ -85,7 +84,15 @@ export function ProviderConfigModal({
const [draftModels, setDraftModels] = useState([]);
const hasApiKey = !!provider.api_key && provider.api_key.length > 0;
- const isOllama = isOllamaProvider(provider);
+ const isOllama = isOllamaProviderRow(provider);
+ const isOnnx = isOnnxProviderRow(provider);
+ const [downloadedIds, setDownloadedIds] = useState([]);
+ const [downloadingIds, setDownloadingIds] = useState([]);
+ const [onnxSizeById, setOnnxSizeById] = useState>({});
+ const [downloadProgressOpen, setDownloadProgressOpen] = useState(false);
+ const [downloadProgress, setDownloadProgress] = useState(0);
+ const [downloadProgressLabel, setDownloadProgressLabel] = useState("");
+ const [downloadProgressModel, setDownloadProgressModel] = useState("");
// === Ollama states ===
const [downloadForm] = Form.useForm();
@@ -98,6 +105,61 @@ export function ProviderConfigModal({
const ollamaPollRef = useRef | null>(null);
const ollamaNotifiedRef = useRef>(new Set());
+ const enableModelAfterDownload = useCallback(
+ async (modelId: string) => {
+ const currentDefault = (
+ form.getFieldValue("model") as string | undefined
+ )?.trim();
+ const nextModels = draftModels.some((m) => m.id === modelId)
+ ? draftModels.map((m) =>
+ m.id === modelId ? { ...m, enabled: true } : m,
+ )
+ : [
+ ...draftModels,
+ {
+ id: modelId,
+ name: modelId,
+ enabled: true,
+ ...(isOnnx
+ ? { embedding: true as const, task: "embedding" as const }
+ : {}),
+ input: ["text"],
+ thinking: null,
+ },
+ ];
+ setDraftModels(nextModels);
+ setFormDirty(true);
+ const defaultModel = currentDefault || modelId;
+ if (!currentDefault) {
+ form.setFieldValue("model", modelId);
+ }
+ try {
+ await request(`${apiPrefix}/${provider.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ models: nextModels,
+ model: defaultModel,
+ }),
+ });
+ if (isOnnx) {
+ await onnxModelApi.updateConfig({
+ enabled: true,
+ model: modelId,
+ download_if_missing: false,
+ });
+ }
+ await onSaved();
+ } catch (err) {
+ message.warning(
+ err instanceof Error
+ ? err.message
+ : t("models.enableAfterDownloadFailed"),
+ );
+ }
+ },
+ [apiPrefix, draftModels, form, isOnnx, onSaved, provider.id, t],
+ );
+
const stopOllamaPolling = useCallback(() => {
if (ollamaPollRef.current) {
clearInterval(ollamaPollRef.current);
@@ -110,13 +172,16 @@ export function ProviderConfigModal({
setOllamaUnavailable(false);
try {
const data = await request("/ollama-models");
- setOllamaModels(Array.isArray(data) ? data : []);
+ const list = Array.isArray(data) ? data : [];
+ setOllamaModels(list);
+ setDownloadedIds(list.map((m) => m.name));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("503") || msg.includes("connect")) {
setOllamaUnavailable(true);
}
setOllamaModels([]);
+ setDownloadedIds([]);
} finally {
setLoadingOllama(false);
}
@@ -143,6 +208,7 @@ export function ProviderConfigModal({
if (!ollamaNotifiedRef.current.has(task.task_id)) {
ollamaNotifiedRef.current.add(task.task_id);
if (task.status === "completed") {
+ void enableModelAfterDownload(task.name);
message.success(t("models.localDownloadSuccess"));
needsRefresh = true;
} else if (task.status === "cancelled") {
@@ -163,7 +229,13 @@ export function ProviderConfigModal({
} catch {
/* ignore polling errors */
}
- }, [t, onSaved, fetchOllamaModels, stopOllamaPolling]);
+ }, [
+ enableModelAfterDownload,
+ t,
+ onSaved,
+ fetchOllamaModels,
+ stopOllamaPolling,
+ ]);
const startOllamaPolling = useCallback(() => {
if (ollamaPollRef.current) return;
@@ -274,6 +346,234 @@ export function ProviderConfigModal({
});
};
+ const refreshDownloadedIds = useCallback(async () => {
+ try {
+ if (isOllama) {
+ const data = await request("/ollama-models");
+ const list = Array.isArray(data) ? data : [];
+ setOllamaModels(list);
+ setDownloadedIds(list.map((m) => m.name));
+ setOllamaUnavailable(false);
+ } else if (isOnnx) {
+ const st = await onnxModelApi.getStatus();
+ setDownloadedIds(st.local_models || []);
+ }
+ } catch (err) {
+ if (isOllama) {
+ const msg = err instanceof Error ? err.message : String(err);
+ if (
+ msg.includes("503") ||
+ msg.includes("connect") ||
+ msg.includes("disabled")
+ ) {
+ setOllamaUnavailable(true);
+ }
+ setOllamaModels([]);
+ setDownloadedIds([]);
+ }
+ }
+ }, [isOllama, isOnnx]);
+
+ const formatSizeGb = useCallback(
+ (sizeGb?: number | null) => {
+ if (sizeGb == null || Number.isNaN(sizeGb)) {
+ return t("models.localDownloadSizeUnknown");
+ }
+ if (sizeGb < 0.01) return `${Math.round(sizeGb * 1024)} MB`;
+ if (sizeGb < 1) return `${(sizeGb * 1024).toFixed(0)} MB`;
+ return `${sizeGb.toFixed(2)} GB`;
+ },
+ [t],
+ );
+
+ const handleOnnxDownloadTerminal = useCallback(
+ async (modelId: string, status: string, error?: string | null) => {
+ setDownloadingIds((prev) => prev.filter((id) => id !== modelId));
+ setDownloadProgressOpen(false);
+ await refreshDownloadedIds();
+ if (status === "done") {
+ await enableModelAfterDownload(modelId);
+ message.success(t("models.onnxDownloadDone", { model: modelId }));
+ } else {
+ message.error(error || t("models.onnxDownloadFailed"));
+ }
+ },
+ [enableModelAfterDownload, refreshDownloadedIds, t],
+ );
+
+ const dismissDownloadProgressToBackground = useCallback(() => {
+ setDownloadProgressOpen(false);
+ setOnnxDownloadProgressHandler(undefined);
+ message.info(t("models.localDownloadBackground"));
+ }, [t]);
+
+ const runOnnxDownloadWithProgress = useCallback(
+ async (modelId: string) => {
+ setDownloadingIds((prev) =>
+ prev.includes(modelId) ? prev : [...prev, modelId],
+ );
+ setDownloadProgressModel(modelId);
+ setDownloadProgress(0);
+ setDownloadProgressLabel(t("models.localDownloadPreparing"));
+ setDownloadProgressOpen(true);
+ try {
+ await onnxModelApi.download(modelId);
+ watchOnnxDownload({
+ modelId,
+ onProgress: (d) => {
+ const pct = Math.max(
+ 0,
+ Math.min(100, Math.round((d.progress || 0) * 100)),
+ );
+ setDownloadProgress(pct);
+ if (d.status === "loading") {
+ setDownloadProgressLabel(
+ t("models.onnxDownloadLoading", { model: modelId }),
+ );
+ } else if (d.status === "downloading") {
+ setDownloadProgressLabel(
+ t("models.onnxDownloadProgress", {
+ model: modelId,
+ percent: pct,
+ }),
+ );
+ }
+ },
+ onTerminal: (d) =>
+ handleOnnxDownloadTerminal(modelId, d.status, d.error),
+ });
+ } catch (err) {
+ setDownloadingIds((prev) => prev.filter((id) => id !== modelId));
+ setDownloadProgressOpen(false);
+ message.error(
+ err instanceof Error ? err.message : t("models.onnxDownloadFailed"),
+ );
+ }
+ },
+ [handleOnnxDownloadTerminal, t],
+ );
+
+ // If progress UI remounts / reopens while a watch is running, re-attach handler.
+ useEffect(() => {
+ if (!downloadProgressOpen) return;
+ setOnnxDownloadProgressHandler((d) => {
+ const pct = Math.max(
+ 0,
+ Math.min(100, Math.round((d.progress || 0) * 100)),
+ );
+ setDownloadProgress(pct);
+ const modelId = d.model_name || downloadProgressModel;
+ if (d.status === "loading") {
+ setDownloadProgressLabel(
+ t("models.onnxDownloadLoading", { model: modelId }),
+ );
+ } else if (d.status === "downloading") {
+ setDownloadProgressLabel(
+ t("models.onnxDownloadProgress", { model: modelId, percent: pct }),
+ );
+ }
+ });
+ return () => setOnnxDownloadProgressHandler(undefined);
+ }, [downloadProgressOpen, downloadProgressModel, t]);
+
+ // Keep backend watch alive across config-modal close; only stop if still idle.
+ useEffect(() => {
+ return () => {
+ // Do not stopWatchingOnnxDownload on unmount — backend download continues
+ // and the module-level watcher will still fire onTerminal (toast).
+ setOnnxDownloadProgressHandler(undefined);
+ };
+ }, []);
+
+ const handleLocalModelDownload = useCallback(
+ async (modelId: string) => {
+ if (isOllama) {
+ Modal.confirm({
+ title: t("models.localDownloadConfirmTitle"),
+ content: t("models.localDownloadConfirmOllama", { name: modelId }),
+ okText: t("models.localDownloadModel"),
+ cancelText: t("common.cancel"),
+ onOk: async () => {
+ setDownloadingIds((prev) =>
+ prev.includes(modelId) ? prev : [...prev, modelId],
+ );
+ try {
+ const task = await ollamaModelApi.downloadOllamaModel({
+ name: modelId,
+ });
+ setOllamaTasks((prev) => [...prev, task]);
+ message.info(t("models.localDownloading", { repo: modelId }));
+ startOllamaPolling();
+ } catch (err) {
+ message.error(
+ err instanceof Error
+ ? err.message
+ : t("models.localDownloadFailed"),
+ );
+ } finally {
+ setDownloadingIds((prev) => prev.filter((id) => id !== modelId));
+ }
+ },
+ });
+ return;
+ }
+
+ if (!isOnnx) return;
+
+ let sizeGb = onnxSizeById[modelId];
+ try {
+ const meta = await onnxModelApi.getModelMeta(modelId);
+ if (meta.size_gb != null) {
+ sizeGb = meta.size_gb;
+ setOnnxSizeById((prev) => ({ ...prev, [modelId]: meta.size_gb! }));
+ }
+ } catch {
+ /* use cached / unknown */
+ }
+
+ Modal.confirm({
+ title: t("models.localDownloadConfirmTitle"),
+ content: t("models.localDownloadConfirmOnnx", {
+ name: modelId,
+ size: formatSizeGb(sizeGb),
+ }),
+ okText: t("models.localDownloadModel"),
+ cancelText: t("common.cancel"),
+ onOk: () => {
+ void runOnnxDownloadWithProgress(modelId);
+ },
+ });
+ },
+ [
+ formatSizeGb,
+ isOllama,
+ isOnnx,
+ onnxSizeById,
+ runOnnxDownloadWithProgress,
+ startOllamaPolling,
+ t,
+ ],
+ );
+
+ useEffect(() => {
+ if (!open) return;
+ if (isOllama || isOnnx) {
+ void refreshDownloadedIds();
+ }
+ if (isOnnx) {
+ void onnxModelApi
+ .getCatalog()
+ .then((items) => {
+ const map: Record = {};
+ for (const it of items || []) {
+ if (it.size_gb != null) map[it.id] = it.size_gb;
+ }
+ setOnnxSizeById(map);
+ })
+ .catch(() => {});
+ }
+ }, [open, isOllama, isOnnx, refreshDownloadedIds]);
+
// ======================== Form ========================
const apiKeyExtra = useMemo(() => {
@@ -429,6 +729,36 @@ export function ProviderConfigModal({
message.warning(t("models.testDraftNeedModel"));
return;
}
+
+ if (isOnnx) {
+ if (!downloadedIds.includes(modelId)) {
+ message.warning(t("models.onnxTestNeedDownload"));
+ return;
+ }
+ const result = await onnxModelApi.test(modelId);
+ if (result.ok) {
+ const latency =
+ result.latency_ms != null
+ ? t("models.testConnectionLatency", {
+ time: Math.round(result.latency_ms),
+ })
+ : "";
+ message.success(
+ t("models.testConnectionSuccess", {
+ name: modelId,
+ latency,
+ }),
+ );
+ } else {
+ message.error(
+ t("models.testConnectionFailed", {
+ error: result.error ?? "unknown",
+ }),
+ );
+ }
+ return;
+ }
+
const draftApiKey = (values.api_key as string | undefined)?.trim();
const draftBaseUrl = (values.base_url as string | undefined)?.trim();
const useDraft =
@@ -440,6 +770,9 @@ export function ProviderConfigModal({
return;
}
+ const embedding = isEmbeddingModel(
+ draftModels.find((m) => m.id === modelId),
+ );
const result =
useDraft || !hasApiKey
? await testProviderDraft({
@@ -448,6 +781,7 @@ export function ProviderConfigModal({
api_key: draftApiKey || provider.api_key || undefined,
base_url: draftBaseUrl || provider.base_url,
model_id: modelId,
+ embedding,
})
: await request<{
ok: boolean;
@@ -455,7 +789,7 @@ export function ProviderConfigModal({
error?: string;
}>(`${apiPrefix}/${provider.id}/test`, {
method: "POST",
- body: JSON.stringify({ model_id: modelId }),
+ body: JSON.stringify({ model_id: modelId, embedding }),
});
if (result.ok) {
@@ -487,6 +821,37 @@ export function ProviderConfigModal({
const handleFetchModels = async () => {
try {
+ if (isOnnx) {
+ setFetchingModels(true);
+ const catalog = await onnxModelApi.getCatalog();
+ const existingIds = new Set(draftModels.map((m) => m.id));
+ const missing = (catalog || []).filter((m) => !existingIds.has(m.id));
+ if (missing.length === 0) {
+ message.info(t("models.fetchModelsNoNew"));
+ return;
+ }
+ const added: ProviderModel[] = missing.map((m) => ({
+ id: m.id,
+ name: m.name || m.id,
+ enabled: false,
+ embedding: true,
+ task: "embedding",
+ input: ["text"],
+ thinking: null,
+ }));
+ setDraftModels((prev) => [...prev, ...added]);
+ setFormDirty(true);
+ const sizeMap: Record = {};
+ for (const it of catalog || []) {
+ if (it.size_gb != null) sizeMap[it.id] = it.size_gb;
+ }
+ setOnnxSizeById((prev) => ({ ...prev, ...sizeMap }));
+ message.success(
+ t("models.fetchModelsMerged", { count: missing.length }),
+ );
+ return;
+ }
+
const values = form.getFieldsValue();
const kind = (values.kind as string | undefined) ?? provider.kind;
if (kind !== "openai") {
@@ -559,7 +924,7 @@ export function ProviderConfigModal({
footer={
- {hasApiKey && (
+ {hasApiKey && !isOnnx && (
{t("models.revokeAuthorization")}
@@ -593,36 +958,59 @@ export function ProviderConfigModal({
-
-
-
+ {!isOnnx && (
+ <>
+
+
+
-
-
-
+
+
+
+ >
+ )}
{/* Default model — Select from the models list, or type freely */}
{draftModels.length ? (