421 lines
17 KiB
Svelte
421 lines
17 KiB
Svelte
<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 你是一名…"></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>
|