PicoBot/webui/src/lib/components/SubAgentDefinitions.svelte

421 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { onMount } from "svelte";
import { api } from "../api.js";
import Icon from "../Icon.svelte";
import StatusBadge from "../StatusBadge.svelte";
let agents = $state([]);
let options = $state({ providers: [], models: [], tools: [], skills: [] });
let loading = $state(true);
let error = $state("");
let editing = $state(null);
let editingError = $state("");
let saving = $state(false);
let reloading = $state(false);
let reloadStatus = $state(null);
let pollTimer = null;
let { notify } = $props();
const phaseBadge = $derived.by(() => {
if (!reloadStatus) return "ok";
const map = { active: "ok", preparing: "run", draining: "run", activating: "run", failed: "fail" };
return map[reloadStatus.phase] || "ok";
});
const phaseLabel = $derived.by(() => {
if (!reloadStatus) return "";
const map = { active: "运行中", preparing: "准备中", draining: "等待任务完成", activating: "激活中", failed: "失败" };
return map[reloadStatus.phase] || reloadStatus.phase;
});
const blank = () => ({
id: "",
description: "",
provider: "",
model: "",
token_limit: null,
max_tool_iterations: null,
tools: [],
skills: [],
delegateMode: "default",
delegates: [],
role_prompt: "",
enabled: true,
});
async function load() {
loading = true;
error = "";
try {
const [a, o] = await Promise.all([
api("/api/agents"),
api("/api/agents/options"),
]);
agents = a.agents || [];
options = o;
} catch (caught) {
error = caught.message;
} finally {
loading = false;
}
}
async function reload() {
reloading = true;
try {
const result = await api("/api/config/reload", { method: "POST" });
notify(result.message || "已触发热重载");
await pollReloadStatus();
} catch (caught) {
notify(caught.message, true);
} finally {
reloading = false;
}
}
async function pollReloadStatus() {
try {
const prev = reloadStatus?.phase;
reloadStatus = await api("/api/config/reload/status");
if (reloadStatus.phase === "active" && prev && prev !== "active") {
await load();
}
} catch {}
}
function startPolling() {
stopPolling();
pollReloadStatus();
pollTimer = setInterval(pollReloadStatus, 3000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function toggleTool(list, name) {
const i = list.indexOf(name);
if (i >= 0) list.splice(i, 1);
else list.push(name);
}
function startNew() {
editing = blank();
editingError = "";
}
function editAgent(agent) {
const delegates = agent.delegates;
let delegateMode = "default";
let list = [];
if (delegates == null) {
delegateMode = "default";
} else if (delegates.includes("*")) {
delegateMode = "any";
} else if (delegates.length === 0) {
delegateMode = "none";
} else {
delegateMode = "list";
list = [...delegates];
}
// Fixing a definition should re-enable it by default.
editing = {
id: agent.id,
description: agent.description || "",
provider: agent.provider || "",
model: agent.model || "",
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: [...(agent.tools || [])],
skills: [...(agent.skills || [])],
delegateMode,
delegates: list,
role_prompt: agent.role_prompt || "",
enabled: agent.load_error || agent.parse_error ? true : agent.enabled !== false,
};
editingError = agent.load_error || agent.parse_error || "";
}
function cancelEdit() {
editing = null;
editingError = "";
}
function delegateLabel(agent) {
const d = agent.delegates;
if (d == null) return "委托: general-purpose默认";
if (d.includes("*")) return "委托: 任意子代理";
if (d.length === 0) return "不可继续委托";
return `委托: ${d.join(", ")}`;
}
async function save() {
if (!editing.id.trim()) {
notify("请填写 Agent ID", true);
return;
}
if (!editing.description.trim()) {
notify("请填写描述", true);
return;
}
if (!editing.role_prompt.trim()) {
notify("请填写角色正文role", true);
return;
}
if (!editing.provider || !editing.model) {
notify("请选择 provider 和 model", true);
return;
}
saving = true;
try {
const payload = {
id: editing.id,
description: editing.description,
provider: editing.provider || null,
model: editing.model || null,
token_limit: editing.token_limit,
max_tool_iterations: editing.max_tool_iterations,
tools: editing.tools,
skills: editing.skills,
role_prompt: editing.role_prompt,
enabled: editing.enabled,
};
if (editing.delegateMode === "none") payload.delegates = [];
else if (editing.delegateMode === "any") payload.delegates = ["*"];
else if (editing.delegateMode === "list") payload.delegates = editing.delegates;
await api("/api/agents", { method: "POST", body: JSON.stringify(payload) });
editing = null;
notify("子代理已保存,点击「热重载」使其生效");
await load();
} catch (caught) {
notify(caught.message, true);
} finally {
saving = false;
}
}
async function toggleEnabled(agent) {
try {
await api("/api/agents", {
method: "POST",
body: JSON.stringify({
id: agent.id,
description: agent.description || "",
provider: agent.provider || null,
model: agent.model || null,
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: agent.tools || [],
skills: agent.skills || [],
role_prompt: agent.role_prompt || "",
enabled: !agent.enabled,
}),
});
agent.enabled = !agent.enabled;
notify(agent.enabled ? "已启用" : "已禁用");
} catch (caught) {
notify(caught.message, true);
}
}
async function remove(agent) {
if (!confirm(`确定删除子代理「${agent.id}」吗?`)) return;
try {
await api(`/api/agents/${encodeURIComponent(agent.id)}`, { method: "DELETE" });
notify("已删除");
await load();
} catch (caught) {
notify(caught.message, true);
}
}
function toolDesc(name) {
const tool = options.tools.find((t) => t.name === name);
return tool?.description || "";
}
onMount(() => {
load();
startPolling();
return stopPolling;
});
</script>
<div class="definitions">
<div class="toolbar">
<div>
<h2 style="margin:0">具名子代理</h2>
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
子代理由 <code>~/.picobot/agents/*.md</code> 定义工具、Skill、Provider 与模型在此直接指定。保存后点击「热重载」使改动生效。
</p>
</div>
<div class="toolbar-actions">
{#if reloadStatus}
<span class="badge {phaseBadge}" title={reloadStatus.last_error || ""}>{phaseLabel}</span>
{/if}
<button class="secondary" onclick={reload} disabled={reloading}><Icon name="refresh" size={15} />{reloading ? "重载中…" : "热重载"}</button>
<button class="primary" onclick={startNew}><Icon name="add" size={16} />新增子代理</button>
</div>
</div>
{#if loading}
<div class="loading">加载中…</div>
{:else if error}
<div class="empty-card error-text">{error}</div>
{:else if agents.length === 0}
<div class="empty-card">暂无子代理定义</div>
{:else}
<div class="cards">
{#each agents as agent (agent.id)}
<article class="card">
<div class="card-row">
<div>
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3>
<p>{agent.description || "(无描述)"}</p>
<div class="meta">
<span>provider: {agent.provider || agent.llm_profile || "—"}</span>
<span>model: {agent.model || "—"}</span>
{#if agent.tools?.length}<span>{agent.tools.length} 个工具</span>{/if}
{#if agent.skills?.length}<span>{agent.skills.length} 个 Skill</span>{/if}
<span>{delegateLabel(agent)}</span>
</div>
{#if agent.tools?.length}
<div class="tag-row">
{#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each}
</div>
{/if}
{#if agent.load_error || agent.parse_error}
<div class="agent-error">
<Icon name="warning" size={14} />
<span>定义错误,已停用:{agent.load_error || agent.parse_error}</span>
</div>
{/if}
</div>
<div class="card-actions">
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button>
{#if !agent.load_error && !agent.parse_error}
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
</button>
{/if}
<button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{#if editing}
<button type="button" class="modal-scrim" onclick={cancelEdit} aria-label="关闭" tabindex="-1"></button>
<div class="modal" role="dialog" aria-label="编辑子代理">
<div class="editor-head">
<div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后点击「热重载」使改动生效</small></div>
<button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button>
</div>
{#if editingError}
<div class="agent-error editor-error">
<Icon name="warning" size={16} />
<span>此定义当前有误,已停用:{editingError}。请修正后保存,再点击「热重载」使其生效。</span>
</div>
{/if}
<div class="agent-form">
<div class="form-row">
<label>ID
<input bind:value={editing.id} placeholder="general-purpose" disabled={!!agents.find((a) => a.id === editing.id)} spellcheck="false" />
</label>
<label>描述
<input bind:value={editing.description} placeholder="通用目的子代理…" />
</label>
</div>
<div class="form-row">
<label>Provider
<select bind:value={editing.provider}>
<option value="">(选择)</option>
{#each options.providers as p (p)}<option value={p}>{p}</option>{/each}
</select>
</label>
<label>Model
<select bind:value={editing.model}>
<option value="">(选择)</option>
{#each options.models as m (m.name)}<option value={m.name}>{m.name}{m.token_limit ? ` · ${m.token_limit} tokens` : " · 默认 128K"}</option>{/each}
</select>
</label>
</div>
<div class="form-row">
<label>token_limit可选上限
<input type="number" bind:value={editing.token_limit} placeholder="留空使用模型;不能超过模型上限" />
</label>
<label>max_tool_iterations
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />
</label>
</div>
<div class="form-label">工具 <small>普通工具可直接启用delegate / emit_signal / agent_task 由运行上下文注入)</small></div>
<div class="tag-row selectable">
{#each options.tools as tool (tool.name)}
<button class="tag pick" class:picked={editing.tools.includes(tool.name)} title={tool.description} onclick={() => toggleTool(editing.tools, tool.name)}>{tool.name}</button>
{/each}
</div>
<div class="form-label">Skills <small>(需要工具集中包含 get_skill</small></div>
<div class="tag-row selectable">
{#each options.skills as skill (skill)}
<button class="tag pick" class:picked={editing.skills.includes(skill)} onclick={() => toggleTool(editing.skills, skill)}>{skill}</button>
{/each}
</div>
<div class="form-label">递归委托 <small>(该子代理可再委托给哪些子代理)</small></div>
<select bind:value={editing.delegateMode}>
<option value="default">默认:仅 general-purpose</option>
<option value="none">不可继续委托</option>
<option value="any">任意子代理</option>
<option value="list">指定列表</option>
</select>
{#if editing.delegateMode === "list"}
<div class="tag-row selectable">
{#each agents.filter((a) => a.id !== editing.id) as agent (agent.id)}
<button class="tag pick" class:picked={editing.delegates.includes(agent.id)} title={agent.description} onclick={() => toggleTool(editing.delegates, agent.id)}>{agent.id}</button>
{/each}
</div>
{/if}
<div class="form-label">角色正文</div>
<textarea bind:value={editing.role_prompt} placeholder="# Role&#10;&#10;你是一名…"></textarea>
</div>
<div class="editor-actions">
<button class="secondary" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="primary" onclick={save} disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
{/if}
</div>
<style>
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.tag { padding: 2px 8px; border: 1px solid var(--line); border-radius: 4px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; font-family: var(--font-mono); }
.tag-row.selectable .tag { cursor: pointer; user-select: none; }
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
.card-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.card-actions button { white-space: nowrap; flex-shrink: 0; }
.agent-error { display: flex; align-items: flex-start; gap: 6px; margin-top: 10px; padding: 8px 10px; border: 1px solid var(--danger-border); border-radius: 6px; color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, transparent); font-size: 12px; line-height: 1.5; }
.editor-error { margin: 14px 18px 0; }
.toolbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
.modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); }
.modal { position: fixed; z-index: 41; top: 6vh; left: 50%; transform: translateX(-50%); width: min(920px, 94vw); max-height: 88vh; overflow: auto; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-16, var(--shadow-8)); }
.editor-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.editor-head strong { display: block; font-size: 15px; }
.editor-head small { color: var(--muted); font-size: 11px; }
.agent-form { display: grid; gap: 14px; padding: 18px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
input, select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--text); background: var(--panel-2); font-size: 13px; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
textarea { min-height: 360px; resize: vertical; font-family: var(--font-mono); line-height: 1.6; }
.form-label { color: var(--muted); font-size: 12px; font-weight: 600; }
.form-label small { font-weight: 400; color: var(--muted); }
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--line); }
@media (max-width: 800px) { .form-row { grid-template-columns: 1fr; } }
</style>