feat(webui): tools and skills browser
This commit is contained in:
parent
0b02fd580b
commit
266fb8c5e9
185
webui/src/pages/ToolsPage.svelte
Normal file
185
webui/src/pages/ToolsPage.svelte
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { Tabs } from "bits-ui";
|
||||||
|
import { api } from "../lib/api.js";
|
||||||
|
|
||||||
|
let tab = $state("tools");
|
||||||
|
let tools = $state([]);
|
||||||
|
let skills = $state([]);
|
||||||
|
let servers = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state("");
|
||||||
|
let query = $state("");
|
||||||
|
let filter = $state("all");
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
["all", "全部"],
|
||||||
|
["read_only", "只读"],
|
||||||
|
["concurrency_safe", "可并发"],
|
||||||
|
["side_effect", "有副作用"],
|
||||||
|
["exclusive", "独占"]
|
||||||
|
];
|
||||||
|
|
||||||
|
function matches(tool) {
|
||||||
|
if (filter === "read_only") return tool.read_only;
|
||||||
|
if (filter === "concurrency_safe") return tool.concurrency_safe;
|
||||||
|
if (filter === "side_effect") return !tool.read_only;
|
||||||
|
if (filter === "exclusive") return tool.exclusive;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return tools.filter(
|
||||||
|
(tool) =>
|
||||||
|
matches(tool) &&
|
||||||
|
(!q || tool.name.toLowerCase().includes(q) || (tool.description || "").toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connected = $derived(servers.filter((server) => server.connected).length);
|
||||||
|
|
||||||
|
function schema(tool) {
|
||||||
|
return tool.parameters_schema ? JSON.stringify(tool.parameters_schema, null, 2) : "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [toolRes, skillRes, status] = await Promise.all([
|
||||||
|
api("/api/tools"),
|
||||||
|
api("/api/skills"),
|
||||||
|
api("/api/status")
|
||||||
|
]);
|
||||||
|
tools = toolRes.tools || [];
|
||||||
|
skills = skillRes.skills || [];
|
||||||
|
servers = status.mcp || [];
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTab(value) {
|
||||||
|
tab = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="page active content-page">
|
||||||
|
<div class="toolbar">
|
||||||
|
<Tabs.Root value={tab} onValueChange={changeTab}>
|
||||||
|
<Tabs.List class="tabs" aria-label="工具与技能">
|
||||||
|
<Tabs.Trigger value="tools">工具</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="skills">Skills</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="mcp">MCP</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs.Root>
|
||||||
|
<button class="secondary" onclick={load}>↻ 刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">加载中…</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="empty-card error-text">{error}</div>
|
||||||
|
{:else if tab === "tools"}
|
||||||
|
<form class="filters-bar" onsubmit={(event) => event.preventDefault()}>
|
||||||
|
<label class="search grow"><span>⌕</span><input bind:value={query} placeholder="搜索工具名称或描述" /></label>
|
||||||
|
<div class="chips">
|
||||||
|
{#each filters as [value, label]}
|
||||||
|
<button type="button" class="chip" class:active={filter === value} onclick={() => (filter = value)}>{label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="cap signal">◇ 只读</span>
|
||||||
|
<span class="cap info">⇉ 可并发</span>
|
||||||
|
<span class="cap accent">△ 有副作用</span>
|
||||||
|
<span class="cap danger">■ 独占</span>
|
||||||
|
</div>
|
||||||
|
<div class="tool-grid">
|
||||||
|
{#each filtered as tool (tool.name)}
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-row">
|
||||||
|
<div>
|
||||||
|
<h3 class="mono">{tool.name}</h3>
|
||||||
|
<div class="meta"><span>{tool.call_count} 次调用</span></div>
|
||||||
|
</div>
|
||||||
|
<span class="badge" class:src-mcp={tool.source === "mcp"}>{tool.source}</span>
|
||||||
|
</div>
|
||||||
|
<p>{tool.description}</p>
|
||||||
|
<div class="cap-row">
|
||||||
|
{#if tool.read_only}<span class="cap signal">◇ 只读</span>{/if}
|
||||||
|
{#if tool.concurrency_safe}<span class="cap info">⇉ 可并发</span>{/if}
|
||||||
|
{#if !tool.read_only}<span class="cap accent">△ 有副作用</span>{/if}
|
||||||
|
{#if tool.exclusive}<span class="cap danger">■ 独占</span>{/if}
|
||||||
|
</div>
|
||||||
|
<details class="schema-wrap">
|
||||||
|
<summary>参数 schema</summary>
|
||||||
|
<pre class="mono">{schema(tool)}</pre>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-card">没有匹配的工具</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if tab === "skills"}
|
||||||
|
<div class="cards">
|
||||||
|
{#each skills as skill (skill.name)}
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-row">
|
||||||
|
<div>
|
||||||
|
<h3 class="mono">{skill.name}</h3>
|
||||||
|
<p>{skill.description}</p>
|
||||||
|
<div class="meta"><span>{skill.source}</span></div>
|
||||||
|
</div>
|
||||||
|
{#if skill.always}<span class="badge ok">always</span>{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-card">暂无 Skills</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="metric"><b>{servers.length}</b><small>MCP 服务器</small></div>
|
||||||
|
<div class="metric"><b>{connected}</b><small>已连接</small></div>
|
||||||
|
<div class="metric"><b>{servers.reduce((sum, server) => sum + (server.tools || 0), 0)}</b><small>工具总数</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="cards">
|
||||||
|
{#each servers as server (server.name)}
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-row">
|
||||||
|
<div>
|
||||||
|
<h3 class="mono">{server.name}</h3>
|
||||||
|
<div class="meta"><span>{server.tools || 0} 个工具</span></div>
|
||||||
|
</div>
|
||||||
|
<span class="badge" class:ok={server.connected} class:fail={!server.connected}>{server.connected ? "已连接" : "未连接"}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-card">暂无 MCP 服务器</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.filters-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.chips { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||||
|
.chip { border: 1px solid var(--line); background: var(--panel); color: var(--muted); border-radius: 99px; padding: 6px 12px; font-size: 11px; cursor: pointer; }
|
||||||
|
.chip.active { background: var(--accent-soft); border-color: var(--accent-border); color: var(--accent); }
|
||||||
|
.legend { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
|
.tool-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
|
||||||
|
.cap-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.badge.src-mcp { background: var(--info-soft); color: var(--info); }
|
||||||
|
.schema-wrap { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
|
||||||
|
.schema-wrap summary { cursor: pointer; color: var(--muted); font-size: 11px; }
|
||||||
|
.schema-wrap pre { max-height: 300px; margin: 8px 0 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.tool-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
x
Reference in New Issue
Block a user