- 移除 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>
1691 lines
48 KiB
Svelte
1691 lines
48 KiB
Svelte
<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>
|