Skip to content

Commit 9ab5de8

Browse files
inhaiinhai
authored andcommitted
feat(config-ui): enrich config UI with skills, MCP, agents, assets and model catalog
- Add Skills / MCP / Agents / Assets inventory views with click-to-open right-side detail drawers (reusable infoDrawer) - Render SKILL.md as Markdown via a self-contained, XSS-safe inline renderer (HTML-escape first, strip YAML frontmatter, no external deps) - Add local vs remote origin badges to Skills and MCP items - Add quick-launch for coding agents (allowlisted id->binary, execFile, no shell); gate the button on Connected AND the CLI binary being on PATH - Add per-category model catalog surfaced as click-to-fill suggestion chips under each default_*_model field, sourced from real bl pipeline model names - Add assets browser (categorized, time-sorted) with preview, open-locally and delete, backed by path-traversal-guarded file serving - Convert Profiles to a tile grid with an add-tile and design-consistent new-profile modal; make view headers sticky and use drawers for editing - Tests for inventory, agent-launch, assets and config-ui endpoints
1 parent 1f91fa4 commit 9ab5de8

12 files changed

Lines changed: 2264 additions & 205 deletions

File tree

docs/agents/config-profile-change.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
- `config list` 标识所有 Profile 与当前激活项。
4747
- `config show``auth status` 只输出本次最终选择的 `config``config_file`,不重复携带激活状态。
4848
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`
49-
- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
49+
- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*``telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。
50+
- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。
51+
- `config ui` 提供 Assets 资产管理:扫描 `output_dir`(默认 `~/bailian-output`)下的 `images/videos/speech/omni` 分类及根目录散落文件,按分类与生成时间(mtime)标记,支持按分类筛选、内联预览(图/视频/音频)与删除单个文件;文件读取与删除均通过限定在输出目录内的路径校验(防目录穿越)。
5052
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
5153

5254
## 6. 最小测试矩阵
@@ -62,7 +64,8 @@
6264
`--config default` 成功后切回 `default`
6365
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
6466
- `config list/show/use/ui``auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
65-
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
67+
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
68+
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
6669

6770
## 7. 完成检查
6871

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Best-effort local launcher for coding-agent CLIs surfaced in the config UI.
3+
*
4+
* The command for each agent is taken from a fixed allowlist keyed by the
5+
* agent id, so no user-controlled string is ever executed. Every child process
6+
* is spawned via `execFile` (array args, no shell) to avoid injection.
7+
*/
8+
import { execFile } from "node:child_process";
9+
10+
/** Fixed allowlist: agent id -> launch binary. Keys match `AGENT_PROBES` ids. */
11+
export const AGENT_COMMANDS: Record<string, string> = {
12+
"claude-code": "claude",
13+
"qwen-code": "qwen",
14+
opencode: "opencode",
15+
openclaw: "openclaw",
16+
hermes: "hermes",
17+
codex: "codex",
18+
};
19+
20+
/** The launch binary for a known agent id, or undefined when unknown. */
21+
export function agentCommand(id: string): string | undefined {
22+
return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined;
23+
}
24+
25+
/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */
26+
function onPath(bin: string): Promise<boolean> {
27+
const cmd = process.platform === "win32" ? "where" : "which";
28+
return new Promise((resolve) => {
29+
execFile(cmd, [bin], { windowsHide: true }, (err) => resolve(!err));
30+
});
31+
}
32+
33+
/**
34+
* Whether a known agent can actually be quick-launched right now: its id maps to
35+
* a launch binary and that binary is reachable on PATH. Unknown ids resolve to
36+
* false. Used to gate the UI's Quick launch button so "Connected" agents whose
37+
* CLI is not installed do not offer a launch that would immediately fail.
38+
*/
39+
export function agentLaunchable(id: string): Promise<boolean> {
40+
const command = agentCommand(id);
41+
if (!command) return Promise.resolve(false);
42+
return onPath(command);
43+
}
44+
45+
/** Single-quote a path for a POSIX shell command line. */
46+
function shQuote(p: string): string {
47+
return `'${p.replace(/'/g, "'\\''")}'`;
48+
}
49+
50+
/** Open a new OS terminal window that cd's into `cwd` and runs `command`. */
51+
function spawnTerminal(command: string, cwd: string): Promise<void> {
52+
const platform = process.platform;
53+
return new Promise((resolve, reject) => {
54+
if (platform === "darwin") {
55+
const inner = `cd ${shQuote(cwd)} && ${command}`;
56+
const escaped = inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
57+
const args = [
58+
"-e",
59+
`tell application "Terminal" to do script "${escaped}"`,
60+
"-e",
61+
'tell application "Terminal" to activate',
62+
];
63+
execFile("osascript", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
64+
return;
65+
}
66+
if (platform === "win32") {
67+
const args = ["/c", "start", "", "cmd", "/k", `cd /d ${cwd} && ${command}`];
68+
execFile("cmd", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
69+
return;
70+
}
71+
// Linux / other: best-effort via the distro's default terminal emulator.
72+
const inner = `cd ${shQuote(cwd)} && ${command}; exec $SHELL`;
73+
execFile("x-terminal-emulator", ["-e", "bash", "-lc", inner], { windowsHide: true }, (err) =>
74+
err ? reject(new Error("No supported terminal emulator was found")) : resolve(),
75+
);
76+
});
77+
}
78+
79+
export interface LaunchResult {
80+
launched: boolean;
81+
command: string;
82+
}
83+
84+
/**
85+
* Launch a known coding agent's local CLI in a new terminal window.
86+
* Rejects when the id is unknown, the binary is missing from PATH, or the
87+
* platform terminal could not be opened.
88+
*/
89+
export async function launchAgent(id: string, cwd: string = process.cwd()): Promise<LaunchResult> {
90+
const command = agentCommand(id);
91+
if (!command) throw new Error(`Unknown agent: ${id}`);
92+
if (!(await onPath(command))) {
93+
throw new Error(`\`${command}\` was not found on your PATH — install ${id} first.`);
94+
}
95+
await spawnTerminal(command, cwd);
96+
return { launched: true, command };
97+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Read/manage the local assets that `bl` writes into the output directory
2+
// (default ~/bailian-output, overridable via the `output_dir` config key).
3+
// Generated media is organized into per-type subdirectories: images/, videos/,
4+
// speech/, omni/. This module discovers those files, classifies them, and
5+
// provides safe path resolution for serving/deleting individual assets.
6+
import { readdirSync, statSync, existsSync, type Dirent } from "node:fs";
7+
import { homedir } from "node:os";
8+
import { join, extname, relative, resolve, sep } from "node:path";
9+
10+
export type AssetKind = "image" | "video" | "audio" | "other";
11+
12+
/** One generated file discovered under the output directory. */
13+
export interface AssetInfo {
14+
name: string;
15+
/** Category folder the file lives in: images | videos | speech | omni | other. */
16+
category: string;
17+
kind: AssetKind;
18+
/** Path relative to the output base (used as the API handle). */
19+
relPath: string;
20+
size: number;
21+
/** Modification time in epoch milliseconds ~= generation time. */
22+
mtime: number;
23+
ext: string;
24+
}
25+
26+
/** Category subdirectories that `bl` writes generated media into. */
27+
const CATEGORY_DIRS = ["images", "videos", "speech", "omni"] as const;
28+
29+
const KIND_BY_EXT: Record<string, AssetKind> = {
30+
".png": "image",
31+
".jpg": "image",
32+
".jpeg": "image",
33+
".webp": "image",
34+
".gif": "image",
35+
".bmp": "image",
36+
".svg": "image",
37+
".mp4": "video",
38+
".mov": "video",
39+
".webm": "video",
40+
".mkv": "video",
41+
".avi": "video",
42+
".mp3": "audio",
43+
".wav": "audio",
44+
".m4a": "audio",
45+
".aac": "audio",
46+
".flac": "audio",
47+
".ogg": "audio",
48+
};
49+
50+
const CONTENT_TYPE: Record<string, string> = {
51+
".png": "image/png",
52+
".jpg": "image/jpeg",
53+
".jpeg": "image/jpeg",
54+
".webp": "image/webp",
55+
".gif": "image/gif",
56+
".bmp": "image/bmp",
57+
".svg": "image/svg+xml",
58+
".mp4": "video/mp4",
59+
".mov": "video/quicktime",
60+
".webm": "video/webm",
61+
".mkv": "video/x-matroska",
62+
".avi": "video/x-msvideo",
63+
".mp3": "audio/mpeg",
64+
".wav": "audio/wav",
65+
".m4a": "audio/mp4",
66+
".aac": "audio/aac",
67+
".flac": "audio/flac",
68+
".ogg": "audio/ogg",
69+
};
70+
71+
/** The default output base when `output_dir` is not configured. */
72+
export function defaultOutputBase(home: string = homedir()): string {
73+
return join(home, "bailian-output");
74+
}
75+
76+
function kindOf(ext: string): AssetKind {
77+
return KIND_BY_EXT[ext.toLowerCase()] ?? "other";
78+
}
79+
80+
/** MIME type for serving an asset; falls back to a safe binary type. */
81+
export function contentType(ext: string): string {
82+
return CONTENT_TYPE[ext.toLowerCase()] ?? "application/octet-stream";
83+
}
84+
85+
/** Recursively collect regular files under `dir`, descending at most `depth` levels. */
86+
function walk(dir: string, depth: number, out: string[]): void {
87+
let entries: Dirent[];
88+
try {
89+
entries = readdirSync(dir, { withFileTypes: true });
90+
} catch {
91+
return;
92+
}
93+
for (const e of entries) {
94+
const full = join(dir, e.name);
95+
if (e.isDirectory()) {
96+
if (depth > 0) walk(full, depth - 1, out);
97+
} else if (e.isFile() || e.isSymbolicLink()) {
98+
out.push(full);
99+
}
100+
}
101+
}
102+
103+
/**
104+
* List generated assets under `base`, newest first. Scans each known category
105+
* subdirectory plus any loose files directly under the base (grouped as
106+
* "other"). Returns the resolved base so callers can surface it in the UI.
107+
*/
108+
export function listAssets(base: string = defaultOutputBase()): {
109+
base: string;
110+
assets: AssetInfo[];
111+
} {
112+
const assets: AssetInfo[] = [];
113+
if (!existsSync(base)) return { base, assets };
114+
115+
const seen = new Set<string>();
116+
const addFile = (full: string, category: string): void => {
117+
if (seen.has(full)) return;
118+
seen.add(full);
119+
let st;
120+
try {
121+
st = statSync(full);
122+
} catch {
123+
return;
124+
}
125+
if (!st.isFile()) return;
126+
const ext = extname(full);
127+
assets.push({
128+
name: full.split(sep).pop() ?? full,
129+
category,
130+
kind: kindOf(ext),
131+
relPath: relative(base, full),
132+
size: st.size,
133+
mtime: st.mtimeMs,
134+
ext: ext.replace(/^\./, "").toLowerCase(),
135+
});
136+
};
137+
138+
for (const cat of CATEGORY_DIRS) {
139+
const files: string[] = [];
140+
walk(join(base, cat), 4, files);
141+
for (const f of files) addFile(f, cat);
142+
}
143+
144+
// Loose files placed directly under the base directory.
145+
let rootEntries: Dirent[] = [];
146+
try {
147+
rootEntries = readdirSync(base, { withFileTypes: true });
148+
} catch {
149+
rootEntries = [];
150+
}
151+
for (const e of rootEntries) {
152+
if (e.isFile() || e.isSymbolicLink()) addFile(join(base, e.name), "other");
153+
}
154+
155+
assets.sort((a, b) => b.mtime - a.mtime);
156+
return { base, assets };
157+
}
158+
159+
/**
160+
* Resolve a client-supplied relative path to an absolute path strictly inside
161+
* `base`. Returns null for empty input or any path that would escape the base
162+
* (path traversal guard).
163+
*/
164+
export function resolveAssetPath(base: string, relPath: string): string | null {
165+
if (typeof relPath !== "string" || relPath.length === 0) return null;
166+
const root = resolve(base);
167+
const abs = resolve(root, relPath);
168+
if (abs !== root && !abs.startsWith(root + sep)) return null;
169+
return abs;
170+
}

0 commit comments

Comments
 (0)