feat(web): 新增工具列表 API 客户端与 CheckboxList 勾选组件

- api/client.ts: 注册 tools 与 subagentsUpdate 端点常量

- api/tools.ts: 新增 listTools() 调用 /api/tools

- api/subagents.ts: 新增 updateSubagent() 调用 PUT /api/subagents/update

- types.ts: 新增 ToolItem / ToolsListResponse 类型

- ui.tsx: 新增 CheckboxList 组件,支持分组展示、legacy 已选值以可移除标签呈现
This commit is contained in:
oudecheng 2026-07-30 16:55:12 +08:00
parent 89c444ad3f
commit 3ea0c19262
6 changed files with 137 additions and 6 deletions

View File

@ -6,8 +6,10 @@ export const API = {
mcpStatus: '/api/mcp/status',
skills: '/api/skills',
skillsToggle: '/api/skills/toggle',
tools: '/api/tools',
subagents: '/api/subagents',
subagentsToggle: '/api/subagents/toggle',
subagentsUpdate: '/api/subagents/update',
experts: '/api/experts',
expertsToggle: '/api/experts/toggle',
expertsCreate: '/api/experts/create',

View File

@ -1,5 +1,5 @@
import { API, apiGetSilent } from './client'
import type { ExpertListResponse, ExpertItem } from '../components/Settings/types'
import type { ExpertListResponse, ExpertItem, CapabilityPolicy } from '../components/Settings/types'
export function listExperts(): Promise<ExpertListResponse | null> {
return apiGetSilent<ExpertListResponse>(API.experts)
@ -13,7 +13,7 @@ export async function toggleExpert(name: string, scope: string, enabled: boolean
})
}
export async function createExpert(payload: { name: string; description: string; body: string; scope: string }): Promise<Response> {
export async function createExpert(payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy }): Promise<Response> {
return fetch(API.expertsCreate, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -21,7 +21,7 @@ export async function createExpert(payload: { name: string; description: string;
})
}
export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string }): Promise<Response> {
export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy }): Promise<Response> {
return fetch(API.expertsUpdate, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },

View File

@ -1,5 +1,5 @@
import { API, apiGetSilent } from './client'
import type { SubagentListResponse } from '../components/Settings/types'
import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types'
export function listSubagents(): Promise<SubagentListResponse | null> {
return apiGetSilent<SubagentListResponse>(API.subagents)
@ -12,3 +12,16 @@ export async function toggleSubagent(name: string, scope: string, enabled: boole
body: JSON.stringify({ name, scope, enabled }),
})
}
export async function updateSubagent(payload: {
name: string
description?: string
body?: string
capability?: CapabilityPolicy
}): Promise<Response> {
return fetch(API.subagentsUpdate, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}

6
web/src/api/tools.ts Normal file
View File

@ -0,0 +1,6 @@
import { API, apiGetSilent } from './client'
import type { ToolsListResponse } from '../components/Settings/types'
export function listTools(): Promise<ToolsListResponse | null> {
return apiGetSilent<ToolsListResponse>(API.tools)
}

View File

@ -40,13 +40,31 @@ export interface SkillListResponse {
skills: SkillItem[]
}
export interface ToolItem {
name: string
description: string
/** "builtin" 或 "mcp:{server_key}" */
source: string
}
export interface ToolsListResponse {
total: number
tools: ToolItem[]
}
export interface CapabilityPolicy {
allowed_skills?: string[]
denied_skills: string[]
allowed_tools?: string[]
denied_tools: string[]
}
export interface SubagentItem {
name: string
description: string
source: string
disabled_in_scopes: string[]
allowed_tools?: string[]
denied_tools?: string[]
capability?: CapabilityPolicy
}
export interface SubagentListResponse {
@ -63,6 +81,7 @@ export interface ExpertItem {
path?: string
body?: string
disabled_in_scopes: string[]
capability?: CapabilityPolicy
}
export interface ExpertListResponse {
experts_system_enabled: boolean

View File

@ -152,6 +152,97 @@ export function SourceEditor({
)
}
export interface CheckboxListOption {
key: string
label: string
description?: string
/** 可选分组标识,配合 groupBy 使用 */
group?: string
}
interface CheckboxListProps {
options: CheckboxListOption[]
selected: string[]
onChange: (selected: string[]) => void
/** 未在 options 中出现但已选中的值legacy 数据),以可移除标签形式展示 */
extraSelected?: string[]
emptyHint?: string
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx" */
groupBy?: (option: CheckboxListOption) => string
}
/**
* Toggle legacy
*/
export function CheckboxList({ options, selected, onChange, extraSelected = [], emptyHint, groupBy }: CheckboxListProps) {
const toggle = (key: string) =>
onChange(selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key])
const selectedSet = new Set(selected)
const optionsInList = new Set(options.map(o => o.key))
// 仅展示未出现在 options 中的额外已选值
const extra = extraSelected.filter(k => !optionsInList.has(k))
// 分组渲染
const renderOptions = (opts: CheckboxListOption[]) => (
<div className="space-y-1">
{opts.map(option => (
<div key={option.key} className="flex items-center justify-between py-1.5 gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)] font-mono">{option.label}</div>
{option.description && <div className="text-xs text-[var(--text-muted)] truncate">{option.description}</div>}
</div>
<Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} />
</div>
))}
</div>
)
let body: ReactNode
if (options.length === 0) {
body = <p className="text-xs text-[var(--text-muted)]">{emptyHint || '无可用选项'}</p>
} else if (groupBy) {
const groups = new Map<string, CheckboxListOption[]>()
for (const opt of options) {
const g = groupBy(opt)
const arr = groups.get(g) ?? []
arr.push(opt)
groups.set(g, arr)
}
body = (
<div className="space-y-3">
{Array.from(groups.entries()).map(([g, opts]) => (
<div key={g}>
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">{g}</div>
{renderOptions(opts)}
</div>
))}
</div>
)
} else {
body = renderOptions(options)
}
return (
<div className="space-y-2">
{body}
{extra.length > 0 && (
<div>
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1"></div>
<div className="flex flex-wrap gap-1.5">
{extra.map(key => (
<span key={key} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
{key}
<button onClick={() => toggle(key)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
</span>
))}
</div>
</div>
)}
</div>
)
}
export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(name)