Files
aigc-home/calliope-web/src/routes/agents/+page.svelte
T
总工andmultica-agent d882fd62d8 feat(ui): F批——其余页面融合收口与整站规范统一 (HEL-656)
- 移除 Calliope 旧壳死代码:/project/[id] 旧四段编辑器页面与 9 个遗留组件
  (NavRail/StoryStage/AssetsStage/ScriptStage/QueueStage/ActivityPanel/
  JobRow/AssetThumb/ProjectCard),/projects 死页面(308→/ 已有)
- 素材库 /library 迁入 AppShell 新壳:a-chip 筛选、ui/Modal、ConfirmDialog
  实心红两级删除、窄屏单列;移除 A 时期 308→/ 重定向;导航轨/更多菜单新增入口
- 智能体子系统去杂色:agentPalette 默认紫→中性、回退色板收敛为 avatar 五色族;
  AgentChat 气泡按 §3.2/3.6(用户 bg-3/Agent bg-2+line)、进行中指示改青、
  紫色空态图标清除;AgentComposer 天蓝/金芯片收敛
- 首页/工作台/五面板/StageStepper/PromptBlock/JobChip 类名收敛到全局 a-btn/
  a-input/a-tag/field;Stepper 补金色当前下划+已确认✓;镜头序号按规范用
  accent-lo+mono;分页/工作台选择页金色越界改中性或青
- 金色纪律修正:次级文字操作(重试/测试/链接)与必填星标、任务「测试」标签
  去金色;「默认」金标签按 §3.9 保留
- 断点收敛:1023/1279/900/780/700/640 → 1180/760(1440 增强保留)
- canvas/build-scene 可用级 ≤760 纵向堆叠适配 + 硬编码色/旧 fallback 清理;
  AppHeader 去 Calliope 品牌与死链
- favicon 收口:static/favicon.png 金色品牌图标,移除 204 stub 与构建豁免
- i18n 七语种 +3 key(shell.navLibrary/library.loadError/library.subtitle)全对齐
- 新增 e2e/fpages.mjs:F 页面三档走查/重定向/五段导入编辑/规范色断言 38 项

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 16:29:47 +08:00

1691 lines
48 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 lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { fade } from 'svelte/transition';
import AppShell from '$lib/components/shell/AppShell.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import AgentChat from '$lib/components/agent/AgentChat.svelte';
import {
agentApi,
settings,
type AgentMessage,
type AgentSession,
type NamedAgent,
type SkillAdminEntry,
type SkillVersion,
canvasApi,
} from '$lib/api';
import { AGENT_TASK_PROMPTS, AGENT_TASK_TITLE_KEYS, isAgentTaskKind } from '$lib/agentTasks';
import { connectEvents } from '$lib/events';
import { stripSkillFrontmatter } from '$lib/skillFrontmatter';
import { toStore } from 'svelte/store';
import { t } from '$lib/i18n.svelte';
import { toast } from '$lib/toast';
const client = useQueryClient();
// ─────────────────────────────────────────────────────────────────────
// Legacy deep-link contract (Project stages): /agents?project=<id>&task=
// story|script still routes into the canvas flow, not this page.
// ─────────────────────────────────────────────────────────────────────
let legacyRedirect = $state<'pending' | 'off' | 'failed'>('pending');
let legacyError = $state('');
onMount(async () => {
const projectParam = page.url.searchParams.get('project');
const taskParam = page.url.searchParams.get('task');
if (projectParam == null && taskParam == null) {
legacyRedirect = 'off';
return;
}
try {
const pid =
projectParam != null && projectParam !== '' ? Number(projectParam) : null;
const task = isAgentTaskKind(taskParam) ? taskParam : null;
const s = await agentApi.createSession({
...(pid != null && Number.isFinite(pid) ? { project_id: pid } : {}),
...(task ? { title: t(AGENT_TASK_TITLE_KEYS[task]) } : {}),
});
const session = await agentApi.getSession(s.id);
const graph = session.project_id
? await canvasApi.ensureForProject(session.project_id)
: await canvasApi.ensureForSession(session.id);
const url = `/canvas/${graph.canvas.id}?session=${session.id}`;
if (task) sessionStorage.setItem('calliope.canvas.composerPrefill', AGENT_TASK_PROMPTS[task]);
goto(url, { replaceState: true });
} catch (err) {
legacyError = err instanceof Error ? err.message : String(err);
legacyRedirect = 'failed';
}
});
// ─────────────────────────────────────────────────────────────────────
// Registry / sessions / skills / settings
// ─────────────────────────────────────────────────────────────────────
const agentsQuery = createQuery({
queryKey: ['agents-registry'],
queryFn: agentApi.listNamedAgents,
});
const settingsQuery = createQuery({ queryKey: ['settings'], queryFn: settings.get });
const ALL_KEYS = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
// One query for all five agents' sessions (left-column busy flags and
// the per-agent session selector share it).
const allSessionsQuery = createQuery({
queryKey: ['agents-page-sessions'],
queryFn: async () => {
const lists = await Promise.all(ALL_KEYS.map((k) => agentApi.listAgentSessions(k)));
return lists.flat();
},
});
let activeKey = $state<string>(
(() => {
const stored = localStorage.getItem('calliope.agents.activeKey');
return stored && (ALL_KEYS as readonly string[]).includes(stored) ? stored : 'script';
})(),
);
const agents = $derived($agentsQuery.data ?? []);
const activeAgent: NamedAgent | null = $derived(
agents.find((a) => a.key === activeKey) ?? null,
);
const activeSessions = $derived(
($allSessionsQuery.data ?? []).filter((s) => s.agent_key === activeKey),
);
const busyByAgent = $derived.by(() => {
const map: Record<string, boolean> = {};
for (const s of $allSessionsQuery.data ?? []) {
if (s.running) map[s.agent_key ?? ''] = true;
}
return map;
});
let activeId = $state<number | null>(null);
const sessionQuery = createQuery(
toStore(() => ({
queryKey: ['agent-session', activeId],
queryFn: () => agentApi.getSession(activeId!),
enabled: activeId != null,
})),
);
// Session selection per agent, persisted; falls back to the newest.
$effect(() => {
const list = activeSessions;
const key = activeKey;
if (!list.length) {
activeId = null;
return;
}
const stored = Number(localStorage.getItem(`calliope.agents.session.${key}`));
const found = list.find((s) => s.id === stored) ?? list[0];
activeId = found.id;
});
function rememberSession(key: string, id: number | null) {
if (id != null) localStorage.setItem(`calliope.agents.session.${key}`, String(id));
else localStorage.removeItem(`calliope.agents.session.${key}`);
}
function selectAgent(key: string) {
localStorage.setItem('calliope.agents.activeKey', key);
activeKey = key;
resetStreaming();
}
function selectSession(id: number) {
activeId = id;
rememberSession(activeKey, id);
resetStreaming();
}
async function newSession() {
try {
const s = await agentApi.createSession({ agent_key: activeKey });
rememberSession(activeKey, s.id);
await client.invalidateQueries({ queryKey: ['agents-page-sessions'] });
activeId = s.id;
resetStreaming();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
// ─────────────────────────────────────────────────────────────────────
// Chat streaming state (same SSE contract as the canvas page)
// ─────────────────────────────────────────────────────────────────────
let streaming = $state('');
let streamingReasoning = $state('');
let thinkingAgent = $state<string | null>(null);
let liveTools = $state<
{ tool: string; args?: Record<string, unknown> | null; result?: unknown; phase: 'running' | 'done' | 'error' }[]
>([]);
function resetStreaming() {
streaming = '';
streamingReasoning = '';
thinkingAgent = null;
liveTools = [];
}
function appendMessage(sid: number, msg: AgentMessage) {
if (sid !== activeId) return;
client.setQueryData<AgentSession & { messages: AgentMessage[] }>(
['agent-session', sid],
(old) => {
if (!old || !Array.isArray(old.messages)) return old;
if (old.messages.some((m) => m.id === msg.id)) return old;
return { ...old, messages: [...old.messages, msg] };
},
);
}
onMount(() => {
const stop = connectEvents((ev) => {
if (ev.type === 'events.resync') {
client.invalidateQueries({ queryKey: ['agents-page-sessions'] });
if (activeId != null)
client.invalidateQueries({ queryKey: ['agent-session', activeId] });
client.invalidateQueries({ queryKey: ['agents-registry'] });
return;
}
if (ev.type === 'agent.session.updated') {
client.invalidateQueries({ queryKey: ['agents-page-sessions'] });
if (activeId != null)
client.invalidateQueries({ queryKey: ['agent-session', activeId] });
return;
}
if (ev.type === 'agent.message') {
const msg = ev.data?.message as AgentMessage | undefined;
if (msg) {
appendMessage(msg.session_id, msg);
if (msg.session_id === activeId && msg.role === 'tool') liveTools = [];
if (msg.session_id === activeId && msg.role === 'assistant') resetStreaming();
}
return;
}
if (ev.data?.session_id !== activeId) return;
if (ev.type === 'agent.token') {
if (!ev.data?.agent_name) streaming += String(ev.data?.content ?? '');
} else if (ev.type === 'agent.thinking') {
const name = (ev.data?.agent_name as string | undefined) ?? null;
if (name !== thinkingAgent) {
streamingReasoning += `${name ? `${name}: ` : ''}`;
thinkingAgent = name;
}
streamingReasoning += String(ev.data?.content ?? '');
} else if (ev.type === 'agent.tool') {
const name = String(ev.data?.tool ?? '');
const phase = String(ev.data?.phase ?? '');
if (phase === 'start') {
liveTools.push({
tool: name,
args: (ev.data?.args as Record<string, unknown>) ?? null,
phase: 'running',
});
} else if (phase === 'finish') {
const idx = [...liveTools].reverse().findIndex((x) => x.tool === name && x.phase === 'running');
if (idx >= 0) {
const real = liveTools.length - 1 - idx;
liveTools[real] = {
...liveTools[real],
result: ev.data?.result,
phase: (ev.data?.result as { ok?: boolean } | undefined)?.ok === false ? 'error' : 'done',
};
}
}
}
});
return stop;
});
const running = $derived($sessionQuery.data?.running === true);
const messages = $derived($sessionQuery.data?.messages ?? []);
// ─────────────────────────────────────────────────────────────────────
// Composer (spec §4.3: Enter 发送 / Shift+Enter 换行)
// ─────────────────────────────────────────────────────────────────────
let draft = $state('');
async function handleSend() {
const content = draft.trim();
if (!content || running) return;
let sid = activeId;
try {
if (sid == null) {
const s = await agentApi.createSession({ agent_key: activeKey });
sid = s.id;
rememberSession(activeKey, sid);
await client.invalidateQueries({ queryKey: ['agents-page-sessions'] });
activeId = sid;
}
draft = '';
await agentApi.postMessage(sid, content);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
void handleSend();
}
}
function handleAnswer(option: string, _scope: string, questionSeq: number) {
if (running || activeId == null) return;
draft = option;
void (async () => {
const content = option;
draft = '';
try {
await agentApi.postMessage(activeId!, { content, mentions: [], attachments: [], answer_to: questionSeq });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
})();
}
// Per-agent opening suggestions for the empty timeline.
const SUGGESTIONS: Record<string, { labelKey: string; promptKey: string }[]> = {
script: [
{ labelKey: 'agentsPage.sugScript1', promptKey: 'agentsPage.sugScript1P' },
{ labelKey: 'agentsPage.sugScript2', promptKey: 'agentsPage.sugScript2P' },
],
style: [
{ labelKey: 'agentsPage.sugStyle1', promptKey: 'agentsPage.sugStyle1P' },
{ labelKey: 'agentsPage.sugStyle2', promptKey: 'agentsPage.sugStyle2P' },
],
storyboard: [
{ labelKey: 'agentsPage.sugBoard1', promptKey: 'agentsPage.sugBoard1P' },
{ labelKey: 'agentsPage.sugBoard2', promptKey: 'agentsPage.sugBoard2P' },
],
assets: [
{ labelKey: 'agentsPage.sugAssets1', promptKey: 'agentsPage.sugAssets1P' },
{ labelKey: 'agentsPage.sugAssets2', promptKey: 'agentsPage.sugAssets2P' },
],
shot: [
{ labelKey: 'agentsPage.sugShot1', promptKey: 'agentsPage.sugShot1P' },
{ labelKey: 'agentsPage.sugShot2', promptKey: 'agentsPage.sugShot2P' },
],
};
const suggestions = $derived(
(SUGGESTIONS[activeKey] ?? []).map((s) => ({ label: t(s.labelKey), prompt: t(s.promptKey) })),
);
// ─────────────────────────────────────────────────────────────────────
// 清空 / 导出
// ─────────────────────────────────────────────────────────────────────
let clearOpen = $state(false);
const clearMutation = createMutation({
mutationFn: async () => {
if (activeId == null) return;
await agentApi.deleteSession(activeId);
rememberSession(activeKey, null);
const s = await agentApi.createSession({ agent_key: activeKey });
rememberSession(activeKey, s.id);
return s;
},
onSuccess: (s) => {
clearOpen = false;
resetStreaming();
void client.invalidateQueries({ queryKey: ['agents-page-sessions'] });
if (s) activeId = s.id;
},
onError: (err) => toast.error(err instanceof Error ? err.message : String(err)),
});
function exportMarkdown() {
const s = $sessionQuery.data;
if (!s || !messages.length) return;
const name = activeAgent?.name ?? activeKey;
const lines = [`# ${name} · ${s.title}`, ''];
for (const m of messages) {
if (m.role === 'user') lines.push(`## 🧑 用户`, '', m.content, '');
else if (m.role === 'assistant') lines.push(`## 🤖 ${m.agent_name ?? name}`, '', m.content, '');
else if (m.role === 'tool' && m.tool_name)
lines.push(`> 工具调用 \`${m.tool_name}\``, '');
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `agents-${activeKey}-${s.id}.md`;
a.click();
URL.revokeObjectURL(url);
}
// ─────────────────────────────────────────────────────────────────────
// Per-agent model binding (shares data with the models page panel)
// ─────────────────────────────────────────────────────────────────────
const assignMutation = createMutation({
mutationFn: (payload: Record<string, string | null>) =>
settings.update({ agent_llm_assignments: payload }),
onSuccess: () => client.invalidateQueries({ queryKey: ['settings'] }),
onError: (err) => {
void client.invalidateQueries({ queryKey: ['settings'] });
toast.error(err instanceof Error ? err.message : String(err));
},
});
function setAgentModel(role: string, value: string) {
// Single-role delta only (HEL-619): the whole-snapshot replay races
// concurrent switches; the server merges per role.
$assignMutation.mutate({ [role]: value === '' ? null : value });
}
// ─────────────────────────────────────────────────────────────────────
// Skill management (right column)
// ─────────────────────────────────────────────────────────────────────
const skillsQuery = createQuery({
queryKey: ['skills-admin'],
queryFn: agentApi.listSkillsAdmin,
});
const skills = $derived($skillsQuery.data ?? []);
type EditorMode = 'idle' | 'add' | 'edit';
let editorMode = $state<EditorMode>('idle');
let editingDir = $state<string | null>(null);
let form = $state({ name: '', type: 'template', desc: '', content: '' });
let formBindings = $state<Record<string, boolean>>({});
let showSkillErrors = $state(false);
let drawerOpen = $state(false);
let versionsOpen = $state(false);
let versions = $state<SkillVersion[]>([]);
const skillErrors = $derived.by(() => {
const errs: Record<string, string> = {};
if (!form.name.trim()) errs.name = t('agentsPage.skillErrName');
if (editorMode === 'edit' && Object.values(formBindings).every((v) => !v))
errs.bindings = t('agentsPage.skillErrAssign');
return errs;
});
function resetEditor() {
editorMode = 'idle';
editingDir = null;
form = { name: '', type: 'template', desc: '', content: '' };
formBindings = {};
showSkillErrors = false;
versionsOpen = false;
versions = [];
drawerOpen = false;
}
function startAddSkill() {
resetEditor();
editorMode = 'add';
// A new skill defaults to the agent being viewed.
formBindings = { [activeKey]: true };
openDrawer();
}
async function startEditSkill(skill: SkillAdminEntry) {
resetEditor();
editorMode = 'edit';
editingDir = skill.dir;
form = {
name: skill.dir,
type: skill.type || 'template',
desc: skill.description,
content: '',
};
formBindings = { ...skill.bindings };
openDrawer();
try {
const res = await agentApi.readSkillFile(skill.dir, 'SKILL.md');
form.content = stripSkillFrontmatter(res.content ?? '');
} catch {
toast.error(t('agentsPage.skillLoadFailed'));
}
}
function openDrawer() {
drawerOpen = true;
}
const saveSkillMutation = createMutation({
mutationFn: async () => {
const dir = editorMode === 'edit' && editingDir ? editingDir : form.name.trim();
return agentApi.upsertSkill(dir, {
description: form.desc.trim(),
skill_type: form.type,
content: form.content,
bindings: formBindings,
});
},
onSuccess: () => {
toast.success(t('agentsPage.skillSaved'));
void client.invalidateQueries({ queryKey: ['skills-admin'] });
void client.invalidateQueries({ queryKey: ['agents-registry'] });
resetEditor();
},
onError: (err) =>
toast.error(
t('agentsPage.skillSaveFailed', { detail: err instanceof Error ? err.message : String(err) }),
),
});
function submitSkill() {
showSkillErrors = true;
if (Object.keys(skillErrors).length || $saveSkillMutation.isPending) return;
$saveSkillMutation.mutate();
}
const toggleSkillMutation = createMutation({
mutationFn: ({ dir, next }: { dir: string; next: boolean }) => agentApi.patchSkill(dir, next),
onSuccess: (_d, vars) => {
toast.success(vars.next ? t('agentsPage.skillEnabled') : t('agentsPage.skillDisabled'));
void client.invalidateQueries({ queryKey: ['skills-admin'] });
void client.invalidateQueries({ queryKey: ['agents-registry'] });
},
onError: (err) => {
void client.invalidateQueries({ queryKey: ['skills-admin'] });
toast.error(err instanceof Error ? err.message : String(err));
},
});
let deleteSkillOpen = $state(false);
let deleteSkillTarget = $state<SkillAdminEntry | null>(null);
const deleteSkillMutation = createMutation({
mutationFn: (dir: string) => agentApi.deleteSkill(dir),
onSuccess: () => {
deleteSkillOpen = false;
toast.success(t('agentsPage.skillDeleted'));
if (editingDir === deleteSkillTarget?.dir) resetEditor();
deleteSkillTarget = null;
void client.invalidateQueries({ queryKey: ['skills-admin'] });
void client.invalidateQueries({ queryKey: ['agents-registry'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : String(err)),
});
async function loadVersions(dir: string) {
try {
versions = await agentApi.listSkillVersions(dir);
versionsOpen = true;
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const restoreMutation = createMutation({
mutationFn: ({ dir, id }: { dir: string; id: number }) => agentApi.restoreSkillVersion(dir, id),
onSuccess: () => {
toast.success(t('agentsPage.skillRestored'));
void client.invalidateQueries({ queryKey: ['skills-admin'] });
if (editingDir) void loadVersions(editingDir);
// Refresh the open editor body to the restored content.
if (editingDir) {
void (async () => {
const skill = $skillsQuery.data?.find((s) => s.dir === editingDir);
if (skill) await startEditSkill(skill);
})();
}
},
onError: (err) => toast.error(err instanceof Error ? err.message : String(err)),
});
function typeLabel(type: string): string {
return type === 'knowledge'
? t('agentsPage.skillTypeKnowledge')
: type === 'rules'
? t('agentsPage.skillTypeRules')
: t('agentsPage.skillTypeTemplate');
}
// Built-in skills ship with English slugs as their directory; show a
// localized name when one exists, otherwise the user's own name.
function skillLabel(dir: string): string {
const key = `skillName.${dir}`;
const label = t(key);
return label === key ? dir : label;
}
function formatUpdatedAt(ts: number | null): string {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString([], {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
/** SQLite CURRENT_TIMESTAMP ships as "YYYY-MM-DD HH:MM:SS" in UTC —
* parse it as such and render in the viewer's local timezone. */
function formatUtcString(raw: string | null | undefined): string {
if (!raw) return '';
const m = raw.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/);
const date = m ? new Date(`${m[1]}T${m[2]}Z`) : new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const installedSkills = $derived(skills.filter((s) => s.bindings?.[activeKey]));
const editingVersion = $derived(
editorMode === 'edit' && editingDir
? (skills.find((s) => s.dir === editingDir)?.version ?? '')
: '',
);
function avatarStyle(n: number): string {
return `var(--avatar-${Math.min(Math.max(n, 1), 5)})`;
}
</script>
{#snippet skillRow(skill: SkillAdminEntry, detail: string)}
<div class="skill-row" class:disabled={!skill.is_enabled}>
<div class="skill-row-main">
<button
type="button"
class="skill-name"
title={`${skill.dir} · ${skill.description}`}
onclick={() => startEditSkill(skill)}
>
{skillLabel(skill.dir)}
</button>
<span class="skill-tags">
<span class="skill-type">{typeLabel(skill.type)}</span>
<span class="skill-assign">{detail}</span>
</span>
</div>
<div class="skill-row-actions">
<button
type="button"
class="icon-btn"
aria-label={t('agentsPage.edit')}
onclick={() => startEditSkill(skill)}
>
<Icon name="edit" size={14} />
</button>
<Switch
checked={skill.is_enabled}
label={t('agentsPage.toggleSkill')}
onchange={(next) => $toggleSkillMutation.mutate({ dir: skill.dir, next })}
/>
</div>
</div>
{/snippet}
{#if legacyRedirect === 'pending'}
<div class="redirecting">{t('agents.opening')}</div>
{:else if legacyRedirect === 'failed'}
<div class="redirecting error" role="alert">{legacyError}</div>
{:else}
<AppShell active="agents" title={t('agentsPage.title')} subtitle={t('agentsPage.subtitle')}>
{#snippet actions()}
<span class="status-chip">
{t('agentsPage.statusChip', {
n: skills.length,
m: skills.filter((s) => s.is_enabled).length,
})}
</span>
{/snippet}
<div class="page">
<div class="cols">
<!-- ── 左栏:五个 Agent + Skill 库(spec §4.3)────────── -->
<aside class="left">
<p class="col-title">{t('agentsPage.agentsTitle')}</p>
<div class="agent-list">
{#each agents as agent (agent.key)}
{@const selected = agent.key === activeKey}
{@const busy = busyByAgent[agent.key] === true}
<button
type="button"
class="agent-row"
class:selected
onclick={() => selectAgent(agent.key)}
>
<span class="avatar" class:dark={agent.avatar_dark} style:background={avatarStyle(agent.avatar)}>
{agent.char}
</span>
<span class="agent-meta">
<span class="agent-name">{agent.name}</span>
<span class="agent-sub">
{t('agentsPage.skillsCount', { n: agent.skill_count })}
·
{busy ? t('agentsPage.replying') : t('agentsPage.idle')}
</span>
</span>
{#if busy}<span class="dot amber breathing"></span>{/if}
</button>
{/each}
</div>
<div class="lib-head">
<p class="col-title">{t('agentsPage.skillLib')}</p>
<span class="col-hint">{t('agentsPage.skillLibCount', { n: skills.length })}</span>
<button type="button" class="a-btn primary sm" onclick={startAddSkill}>
<Icon name="plus" size={13} />
{t('agentsPage.newSkill')}
</button>
</div>
<div class="skill-lib">
{#if !skills.length}
<p class="lib-empty">{t('agentsPage.skillLibEmpty')}</p>
{/if}
{#each skills as skill (skill.dir)}
{@render skillRow(skill, t('agentsPage.assigned', { n: skill.assigned_count }))}
{/each}
</div>
</aside>
<!-- ── 中栏:会话(spec §4.3)────────────────────────── -->
<section class="mid">
{#if activeAgent}
<header class="chat-head">
<span class="avatar lg" class:dark={activeAgent.avatar_dark} style:background={avatarStyle(activeAgent.avatar)}>
{activeAgent.char}
</span>
<div class="chat-head-meta">
<h2>{activeAgent.name}</h2>
<p>{t('agentsPage.independentNote')}</p>
</div>
<span class="status-tag" class:busy={running}>
{#if running}
<span class="dot amber breathing"></span>
{t('agentsPage.replying')}
{:else}
<span class="dot green"></span>
{t('agentsPage.idle')}
{/if}
</span>
<div class="chat-head-actions">
<label class="model-pick">
<span class="model-label">{t('agentsPage.model')}</span>
{#if $settingsQuery.data}
{@const s = $settingsQuery.data}
<select
class="a-select"
value={s.agent_llm_assignments?.[activeAgent.llm_role] ?? ''}
onchange={(e) => setAgentModel(activeAgent.llm_role, e.currentTarget.value)}
>
<option value="">{t('agentsPage.modelDefault')}</option>
{#each s.llm_profiles as p (p.id)}
<option value={p.id}>{p.name} — {p.model}</option>
{/each}
</select>
{/if}
</label>
<select
class="a-select session-select"
value={activeId ?? ''}
onchange={(e) => selectSession(Number(e.currentTarget.value))}
>
{#if !activeSessions.length}
<option value="">{t('agentsPage.newSessionLabel')}</option>
{/if}
{#each activeSessions as s (s.id)}
<option value={s.id}>{s.title}</option>
{/each}
</select>
<button type="button" class="a-btn secondary sm" onclick={newSession}>
<Icon name="plus" size={13} />
{t('agentsPage.newSession')}
</button>
<button
type="button"
class="a-btn secondary sm"
disabled={!messages.length}
onclick={() => (clearOpen = true)}
>
{t('agentsPage.clear')}
</button>
<button
type="button"
class="a-btn secondary sm"
disabled={!messages.length}
onclick={exportMarkdown}
>
<Icon name="download" size={13} />
{t('agentsPage.export')}
</button>
<button type="button" class="a-btn secondary sm manage-btn" onclick={openDrawer}>
<Icon name="sparkle" size={13} />
{t('agentsPage.skillManage')}
</button>
</div>
</header>
<div class="chat-body">
<AgentChat
{messages}
{liveTools}
{streaming}
{streamingReasoning}
running={running === true}
{suggestions}
loading={activeId != null && $sessionQuery.isFetching && !messages.length}
onSuggestion={(p) => (draft = p)}
onAnswer={handleAnswer}
/>
</div>
<footer class="composer">
<textarea
rows="3"
placeholder={t('agentsPage.composerPlaceholder')}
value={draft}
onkeydown={onKeydown}
oninput={(e) => (draft = e.currentTarget.value)}
disabled={running === true}
></textarea>
<button
type="button"
class="a-btn primary send"
disabled={!draft.trim() || running === true}
onclick={handleSend}
>
{t('agentsPage.send')}
<Icon name="play" size={13} />
</button>
</footer>
{/if}
</section>
<!-- ── 右栏:Skill 管理(spec §4.3;窄屏为抽屉)──────── -->
{#if editorMode !== 'idle' || drawerOpen}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="drawer-backdrop" onclick={() => (drawerOpen ? resetEditor() : undefined)} aria-hidden="true"></div>
{/if}
<aside class="right" class:open={editorMode !== 'idle' || drawerOpen} aria-label={t('agentsPage.skillManage')}>
<div class="right-head">
<p class="col-title">
{activeAgent ? t('agentsPage.installedTitle', { name: activeAgent.name }) : t('agentsPage.skillManage')}
</p>
<span class="col-hint">
{t('agentsPage.installedCount', {
n: installedSkills.length,
m: installedSkills.filter((s) => s.is_enabled).length,
})}
</span>
<div class="right-head-actions">
{#if editorMode === 'edit' && editingDir}
<button type="button" class="link-btn" onclick={() => loadVersions(editingDir!)}>
<Icon name="clock" size={13} />
{t('agentsPage.versions')}
</button>
{/if}
{#if editorMode !== 'idle' || drawerOpen}
<button
type="button"
class="icon-btn"
aria-label={t('agentsPage.close')}
onclick={resetEditor}
>
<Icon name="close" size={16} />
</button>
{/if}
</div>
</div>
{#if versionsOpen && editingDir}
<div class="versions" transition:fade={{ duration: 200 }}>
<p class="versions-title">{t('agentsPage.versions')}</p>
{#if !versions.length}
<p class="lib-empty">{t('agentsPage.versionsEmpty')}</p>
{/if}
{#each versions as v (v.id)}
<div class="version-row">
<div class="version-meta">
<span class="mono">#{v.id}</span>
<span class="version-time">{formatUtcString(v.created_at)}</span>
<span class="version-len mono">{v.length}B</span>
</div>
<button
type="button"
class="a-btn secondary xs"
disabled={$restoreMutation.isPending}
onclick={() => $restoreMutation.mutate({ dir: editingDir!, id: v.id })}
>
{t('agentsPage.restore')}
</button>
</div>
{/each}
</div>
{/if}
{#if installedSkills.length}
<div class="skill-lib installed">
{#each installedSkills as skill (skill.dir)}
{@render skillRow(
skill,
[`v${skill.version}`, formatUpdatedAt(skill.updated_at)].filter(Boolean).join(' · '),
)}
{/each}
</div>
{/if}
{#if editorMode === 'idle'}
<div class="form-idle">
<Icon name="sparkle" size={20} />
<p>{t('agentsPage.skillIdleHint')}</p>
</div>
{:else}
<div class="editor" class:editing={editorMode === 'edit'}>
<div class="form-head">
<h3>
{editorMode === 'add'
? t('agentsPage.newSkill')
: t('agentsPage.editSkill')}
</h3>
{#if editorMode === 'edit' && editingVersion}
<span class="ver-chip mono">v{editingVersion}</span>
{/if}
</div>
<label class="f-field">
<span class="f-label">{t('agentsPage.skillName')}<em>*</em></span>
<input
class="f-input"
class:invalid={showSkillErrors && skillErrors.name}
value={form.name}
disabled={editorMode === 'edit'}
oninput={(e) => (form.name = e.currentTarget.value)}
placeholder={t('agentsPage.skillNamePh')}
/>
{#if showSkillErrors && skillErrors.name}
<span class="f-error">{skillErrors.name}</span>
{/if}
</label>
<div class="f-field">
<span class="f-label">{t('agentsPage.skillType')}</span>
<div class="seg" role="radiogroup" aria-label={t('agentsPage.skillType')}>
{#each ['template', 'knowledge', 'rules'] as tp (tp)}
<button
type="button"
class="seg-item"
class:selected={form.type === tp}
role="radio"
aria-checked={form.type === tp}
onclick={() => (form.type = tp)}
>
{typeLabel(tp)}
</button>
{/each}
</div>
</div>
<label class="f-field">
<span class="f-label">{t('agentsPage.skillDesc')}</span>
<input
class="f-input"
value={form.desc}
oninput={(e) => (form.desc = e.currentTarget.value)}
placeholder={t('agentsPage.skillDescPh')}
/>
</label>
<label class="f-field">
<span class="f-label">{t('agentsPage.skillContent')}</span>
<textarea
rows="8"
class="f-input content"
value={form.content}
oninput={(e) => (form.content = e.currentTarget.value)}
placeholder={t('agentsPage.skillContentPh')}
></textarea>
<span class="f-hint">{t('agentsPage.skillContentHint')}</span>
</label>
<div class="f-field">
<span class="f-label">{t('agentsPage.assignTo')}</span>
<div class="capsules">
{#each agents as agent (agent.key)}
<button
type="button"
class="capsule"
class:on={formBindings[agent.key] === true}
onclick={() =>
(formBindings = {
...formBindings,
[agent.key]: !(formBindings[agent.key] === true),
})}
>
{agent.name}
</button>
{/each}
</div>
{#if showSkillErrors && skillErrors.bindings}
<span class="f-error">{skillErrors.bindings}</span>
{/if}
</div>
<div class="editor-actions">
<Button
variant="primary"
size="sm"
loading={$saveSkillMutation.isPending}
onclick={submitSkill}
>
{#if editorMode === 'edit' && editingVersion}
{t('agentsPage.saveNext', { v: editingVersion })}
{:else}
{t('agentsPage.save')}
{/if}
</Button>
{#if editorMode === 'edit'}
<Button
variant="secondary"
size="sm"
onclick={() => {
if (editingDir)
$toggleSkillMutation.mutate({
dir: editingDir,
next: !(skills.find((s) => s.dir === editingDir)?.is_enabled ?? true),
});
}}
>
{skills.find((s) => s.dir === editingDir)?.is_enabled === false
? t('agentsPage.enable')
: t('agentsPage.disable')}
</Button>
<button
type="button"
class="link-danger"
onclick={() => {
deleteSkillTarget = skills.find((s) => s.dir === editingDir) ?? null;
deleteSkillOpen = true;
}}
>
{t('agentsPage.delete')}
</button>
{/if}
</div>
</div>
{/if}
</aside>
</div>
</div>
</AppShell>
<ConfirmDialog
bind:open={clearOpen}
title={t('agentsPage.clearTitle')}
message={t('agentsPage.clearConfirm', { name: activeAgent?.name ?? '' })}
confirmLabel={t('agentsPage.clear')}
danger
onconfirm={() => $clearMutation.mutate()}
oncancel={() => (clearOpen = false)}
/>
<ConfirmDialog
bind:open={deleteSkillOpen}
title={t('agentsPage.delSkillTitle')}
message={t('agentsPage.delSkillConfirm', {
n: deleteSkillTarget?.assigned_count ?? 0,
})}
confirmLabel={t('agentsPage.delete')}
danger
onconfirm={() => deleteSkillTarget && $deleteSkillMutation.mutate(deleteSkillTarget.dir)}
oncancel={() => (deleteSkillTarget = null)}
/>
{/if}
<style>
.redirecting {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
color: var(--text-2);
font-size: 14px;
}
.redirecting.error {
color: var(--error);
}
.page {
padding: 20px 24px 28px;
max-width: 1720px;
width: 100%;
box-sizing: border-box;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.cols {
display: flex;
gap: 18px;
flex: 1;
min-height: 0;
align-items: stretch;
}
/* ── 左栏 ─────────────────────────────────────────────── */
.left {
width: 236px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.col-title {
font-size: 14.5px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.col-hint {
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
}
.agent-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.agent-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
text-align: left;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms;
}
.agent-row:hover {
background: var(--surface-2);
}
.agent-row.selected {
background: var(--surface-3);
border-color: var(--border-strong);
}
.avatar {
width: 34px;
height: 34px;
border-radius: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: var(--text);
flex-shrink: 0;
}
.avatar.dark {
color: var(--accent-ink);
}
.avatar.lg {
width: 42px;
height: 42px;
border-radius: 11px;
font-size: 17px;
}
.agent-meta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.agent-name {
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
.agent-sub {
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lib-head {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.lib-head .a-btn {
margin-left: auto;
}
.skill-lib {
display: flex;
flex-direction: column;
gap: 4px;
}
.lib-empty {
font-size: 11.5px;
color: var(--text-3);
padding: 10px 4px;
margin: 0;
}
/* Skill 行(§3.6):启用 = --run 描边 + 软底;停用 = --text-3 描边。 */
.skill-row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border-radius: var(--r-md);
border: 1px solid color-mix(in srgb, var(--run) 45%, transparent);
background: var(--run-soft);
}
.skill-row.disabled {
border-color: color-mix(in srgb, var(--text-3) 45%, transparent);
background: var(--bg-2);
opacity: 0.75;
}
.skill-row-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.skill-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.skill-name:hover {
color: var(--text);
}
.skill-tags {
display: flex;
gap: 6px;
align-items: center;
}
.skill-type {
font-size: 10px;
padding: 1px 7px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-2);
white-space: nowrap;
}
.skill-assign {
font-size: 10px;
color: var(--text-3);
white-space: nowrap;
}
.skill-row-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ── 中栏 ─────────────────────────────────────────────── */
.mid {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
min-height: 0;
}
.chat-head {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.chat-head-meta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.chat-head-meta h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--text);
}
.chat-head-meta p {
margin: 0;
font-size: 11.5px;
color: var(--text-3);
}
.status-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
background: var(--surface-3);
font-size: 11px;
color: var(--text-2);
white-space: nowrap;
}
.status-tag.busy {
background: var(--run-soft);
color: var(--run);
}
.chat-head-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-wrap: wrap;
}
.model-pick {
display: inline-flex;
align-items: center;
gap: 6px;
}
.model-label {
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
}
.session-select {
max-width: 180px;
}
.chat-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 10px 14px;
}
.composer {
display: flex;
gap: 10px;
align-items: flex-end;
padding: 12px 14px;
border-top: 1px solid var(--border);
}
.composer textarea {
flex: 1;
resize: none;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 10px;
padding: 10px 12px;
color: var(--text);
font-family: inherit;
font-size: 13px;
line-height: 1.55;
min-height: 44px;
max-height: 180px;
}
.composer textarea:focus-visible {
outline: none;
border-color: var(--accent);
}
.composer textarea::placeholder {
color: var(--text-3);
}
.composer .send {
min-height: 38px;
}
/* ── 右栏 ─────────────────────────────────────────────── */
.right {
width: 372px;
flex-shrink: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.right-head {
display: flex;
align-items: center;
gap: 8px;
}
.right-head-actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.link-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: none;
border: none;
color: var(--text-2);
font-size: 11.5px;
cursor: pointer;
padding: 2px 4px;
}
.link-btn:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 3px;
}
.versions {
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--surface-2);
}
.versions-title {
margin: 0 0 2px;
font-size: 11.5px;
font-weight: 600;
color: var(--text-2);
}
.version-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 11px;
color: var(--text-2);
}
.version-meta {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.version-time {
color: var(--text-3);
white-space: nowrap;
}
.version-len {
color: var(--text-3);
}
.form-idle {
margin: auto;
text-align: center;
color: var(--text-3);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 12px;
font-size: 12px;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid var(--accent-glow);
border-radius: 10px;
padding: 12px;
background: var(--surface-2);
}
.form-head {
display: flex;
align-items: center;
gap: 8px;
}
.form-head h3 {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.ver-chip {
padding: 1px 8px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
font-size: 10.5px;
}
.f-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.f-label {
font-size: 11.5px;
font-weight: 600;
color: var(--text-2);
}
.f-label em {
color: var(--err);
font-style: normal;
margin-left: 2px;
}
.f-input {
box-sizing: border-box;
width: 100%;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
}
.f-input:focus-visible {
outline: none;
border-color: var(--accent);
}
.f-input.invalid {
border-color: var(--error);
}
.f-input.content {
resize: vertical;
min-height: 150px;
line-height: 1.55;
}
.f-input::placeholder {
color: var(--text-3);
}
.f-error {
font-size: 11px;
color: var(--error);
}
.f-hint {
font-size: 10.5px;
color: var(--text-3);
}
.seg {
display: inline-flex;
border: 1px solid var(--border-strong);
border-radius: 8px;
overflow: hidden;
}
.seg-item {
padding: 6px 10px;
background: var(--surface-3);
border: none;
color: var(--text-2);
font-size: 11.5px;
cursor: pointer;
}
.seg-item.selected {
background: var(--accent-dim);
color: var(--accent);
font-weight: 600;
}
.seg-item + .seg-item {
border-left: 1px solid var(--border-strong);
}
.capsules {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.capsule {
padding: 4px 11px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
color: var(--text-2);
font-size: 11.5px;
cursor: pointer;
transition: all 150ms;
}
.capsule.on {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
}
.editor-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.editor-actions .link-danger {
margin-left: auto;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--text-2);
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.link-danger {
background: none;
border: none;
color: var(--error);
font-size: 12px;
cursor: pointer;
padding: 4px 6px;
}
.link-danger:hover {
text-decoration: underline;
}
.dot {
width: 7px;
height: 7px;
border-radius: 999px;
display: inline-block;
flex-shrink: 0;
}
.dot.green {
background: var(--success);
}
.dot.amber {
background: var(--run);
}
.dot.amber.breathing {
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 var(--accent-dim);
}
50% {
opacity: 0.45;
box-shadow: 0 0 0 4px var(--accent-dim);
}
}
.chat-head-actions .a-select {
max-width: 220px;
padding: 5px 8px;
}
.manage-btn {
display: none;
}
.drawer-backdrop {
display: none;
}
/* ── 窄屏(1024–1439px):右栏收为抽屉(spec §4.5)────── */
@media (max-width: 1439px) {
.manage-btn {
display: inline-flex;
}
.right {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(372px, 92vw);
z-index: 60;
border-radius: 12px 0 0 12px;
transform: translateX(calc(100% + 18px));
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: var(--shadow-float);
}
.right.open {
transform: translateX(0);
}
.drawer-backdrop {
display: block;
position: fixed;
inset: 0;
background: var(--overlay);
z-index: 55;
}
.session-select {
max-width: 140px;
}
}
@media (max-width: 1179px) {
.model-pick .model-label {
display: none;
}
}
</style>