WIP: HEL-730 管理页面与工作流闭环施工稿 #4

Draft
leefer wants to merge 6 commits from leefer/hel-730-management AGit into feat/hel-729-creative-desk
28 changed files with 5531 additions and 6160 deletions
@@ -1,37 +1,56 @@
<script lang="ts">
import { language, setLanguage, type Language } from '$lib/i18n.svelte';
import { t } from '$lib/i18n.svelte';
import { language, setLanguage, type Language } from "$lib/i18n.svelte";
import { t } from "$lib/i18n.svelte";
let { segmented = false }: { segmented?: boolean } = $props();
const languages: [Language, string][] = [
["zh", "中文"],
["en", "English"],
["ja", "日本語"],
["ko", "한국어"],
["de", "Deutsch"],
["es", "Español"],
["fr", "Français"],
];
</script>
<select
class="lang-switcher"
value={language.current}
aria-label={t('common.language')}
onchange={(e) => setLanguage(e.currentTarget.value as Language)}
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="zh">中文</option>
</select>
{#if segmented}<div class="mg-options" aria-label={t("common.language")}>
{#each languages as [code, label]}<button
class="mg-option"
class:on={language.current === code}
aria-pressed={language.current === code}
onclick={() => setLanguage(code)}>{label}</button
>{/each}
</div>{:else}
<select
class="lang-switcher"
value={language.current}
aria-label={t("common.language")}
onchange={(e) => setLanguage(e.currentTarget.value as Language)}
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="zh">中文</option>
</select>
{/if}
<style>
.lang-switcher {
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
min-height: 34px;
}
.lang-switcher:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
.lang-switcher {
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
min-height: 34px;
}
.lang-switcher:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { isWorkflowRunnable } from '$lib/workflowState';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
@@ -134,7 +135,7 @@
if (index >= limit) limit = index + 24;
});
function runnable(m: WorkflowMeta) {
return m.is_enabled && !!m.usage && !m.needs_mapping;
return isWorkflowRunnable(m);
}
function choose(m: WorkflowMeta) {
if (runnable(m)) onPick(m.id);
@@ -1,4 +1,5 @@
<script lang="ts">
import { isWorkflowRunnable } from '$lib/workflowState';
import { readDeskDraft, writeDeskDraft } from './draftCache';
import HistoryRail from './HistoryRail.svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -123,7 +124,7 @@
const deskMetas = $derived(
allMetas.filter(
(m) =>
m.is_enabled && m.usage !== null && !m.needs_mapping && m.kind === kind,
isWorkflowRunnable(m) && m.kind === kind,
),
);
const pendingCount = $derived(
@@ -1,4 +1,5 @@
<script lang="ts">
import { isWorkflowRunnable } from '$lib/workflowState';
import { tick } from 'svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { workflows, settings, apiErrorMessage } from '$lib/api';
@@ -127,7 +128,7 @@
}
function pick(m: WorkflowMeta) {
if (onPreview) onPreview(m.id);
else if (m.is_enabled && !m.needs_mapping && m.usage) onPick(m.id);
else if (isWorkflowRunnable(m)) onPick(m.id);
}
function move(delta: number) {
const i = Math.max(
@@ -0,0 +1,24 @@
<script lang="ts">
import Button from "$lib/components/ui/Button.svelte";
let { kind = "all" }: { kind?: "all" | "image" | "video" } = $props();
const pending = [
{ kind: "image", name: "云端 · 聚合 API(GPT-Image-2 · Nano Banana Pro)" },
{ kind: "video", name: "云端 · 可灵" },
{ kind: "video", name: "云端 · 即梦 CLI" },
];
</script>
{#each pending.filter((item) => kind === "all" || item.kind === kind) as item}<article
class="mg-card pending"
>
<div class="mg-row">
<span class="mg-dot"></span><b>{item.name}</b><span class="mg-grow"
></span><span class="mg-tag">未接入</span>
</div>
<div class="mg-mono">尚未接入</div>
<div class="mg-muted">后续接入批次 · 当前不可用于生成</div>
<div class="mg-row">
<span class="mg-tag">0 个工作流使用</span><span class="mg-grow"
></span><Button size="sm" disabled>等待接入</Button>
</div>
</article>{/each}
@@ -0,0 +1,645 @@
<script lang="ts">
import CloudModels from "./CloudModels.svelte";
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { fade } from "svelte/transition";
import Modal from "$lib/components/ui/Modal.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 { comfy, settings, workflows, type ComfyEndpoint } from "$lib/api";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
const client = useQueryClient();
// ------------------------------------------------------------------
// Data
// ------------------------------------------------------------------
const settingsQuery = createQuery({
queryKey: ["settings"],
queryFn: settings.get,
});
// HEL-682: the library browses metadata only (view=meta) — the heavy
// workflow_json payloads are fetched per-row inside WorkflowLibrary
// exactly when an action needs them.
const wfMetaQuery = createQuery({
queryKey: ["workflows", "meta"],
queryFn: workflows.listMeta,
});
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
const activeId = $derived(
$settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? "",
);
// ------------------------------------------------------------------
// Local test records — presentational only; the connectivity facts
// always come from the server-side probe.
// ------------------------------------------------------------------
type TestRec = {
ok: boolean;
ms?: number;
version?: string;
at: number;
reason?: string;
};
const TESTS_KEY = "calliope.connections.tests";
let tests = $state<Record<string, TestRec>>({});
let pendingTests = $state<Record<string, boolean>>({});
function loadTests() {
try {
const raw = localStorage.getItem(TESTS_KEY);
if (raw) tests = JSON.parse(raw) as Record<string, TestRec>;
} catch {
tests = {};
}
}
function saveTests(map: Record<string, TestRec>) {
tests = map;
try {
localStorage.setItem(TESTS_KEY, JSON.stringify(map));
} catch {
/* storage disabled — the page still works without persistence */
}
}
function recordTest(id: string, rec: TestRec) {
saveTests({ ...tests, [id]: rec });
}
// ------------------------------------------------------------------
// Connection form (a6: right column, add / edit)
// ------------------------------------------------------------------
type FormMode = "idle" | "add" | "edit";
let formMode = $state<FormMode>("idle");
let editingId = $state<string | null>(null);
let form = $state({
name: "",
baseUrl: "",
authType: "none" as "none" | "bearer" | "header",
token: "",
headerName: "",
notes: "",
});
let formTest = $state<{ ok: boolean; ms?: number; version?: string } | null>(
null,
);
let formTestFailure = $state<{ message: string; detail: string } | null>(
null,
);
let formPending = $state(false);
let showErrors = $state(false);
function resetForm() {
formMode = "idle";
editingId = null;
form = {
name: "",
baseUrl: "",
authType: "none",
token: "",
headerName: "",
notes: "",
};
formTest = null;
formTestFailure = null;
showErrors = false;
}
export function startAdd() {
resetForm();
formMode = "add";
}
function startEdit(ep: ComfyEndpoint) {
resetForm();
formMode = "edit";
editingId = ep.id;
form = {
name: ep.name,
baseUrl: ep.base_url,
authType: ep.auth_type,
token: "",
headerName: ep.header_name ?? "",
notes: ep.notes ?? "",
};
}
function touchFormConnectivity() {
formTest = null;
formTestFailure = null;
}
const urlValid = $derived.by(() => {
try {
const u = new URL(form.baseUrl.trim());
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
});
const formErrors = $derived.by(() => {
const errs: Record<string, string> = {};
if (!form.name.trim()) errs.name = t("conn.errName");
if (!urlValid) errs.baseUrl = t("conn.errUrl");
if (form.authType === "header" && !form.headerName.trim())
errs.headerName = t("conn.errHeaderName");
return errs;
});
const formValid = $derived(Object.keys(formErrors).length === 0);
const editingEndpoint = $derived.by(() => {
if (formMode !== "edit" || !editingId) return null;
return endpoints.find((ep) => ep.id === editingId) ?? null;
});
async function runTest() {
showErrors = true;
if (!formValid) return;
formPending = true;
formTest = null;
formTestFailure = null;
try {
const res = await comfy.testEndpoint({
...(formMode === "edit" && editingId ? { endpoint_id: editingId } : {}),
base_url: form.baseUrl.trim(),
auth_type: form.authType,
...(form.authType !== "none" && form.token.trim()
? { auth_token: form.token.trim() }
: {}),
...(form.authType === "header" && form.headerName.trim()
? { header_name: form.headerName.trim() }
: {}),
timeout_sec: 5,
});
if (res.ok) {
formTest = {
ok: true,
ms: res.elapsed_ms,
version: res.comfyui_version,
};
} else {
formTest = { ok: false };
formTestFailure = {
message: res.message ?? res.detail ?? res.error ?? "",
detail: res.detail ?? "",
};
}
} catch (err) {
formTest = { ok: false };
formTestFailure = {
message: err instanceof Error ? err.message : String(err),
detail: "",
};
} finally {
formPending = false;
}
}
const saveMutation = createMutation({
mutationFn: async () => {
const payload: Record<string, unknown> = {
name: form.name.trim(),
base_url: form.baseUrl.trim(),
auth_type: form.authType,
notes: form.notes.trim() || null,
};
if (form.authType !== "none" && form.token.trim()) {
payload["auth_token"] = form.token.trim();
}
if (form.authType === "header")
payload["header_name"] = form.headerName.trim();
if (formMode === "edit" && editingId) {
return comfy.updateEndpoint(editingId, payload);
}
return comfy.createEndpoint(
payload as Parameters<typeof comfy.createEndpoint>[0],
);
},
onSuccess: (res) => {
client.invalidateQueries({ queryKey: ["settings"] });
if (formTest?.ok && editingId) {
recordTest(editingId, {
ok: true,
ms: formTest.ms,
version: formTest.version,
at: Date.now(),
});
} else if (formTest?.ok && res.endpoints.length) {
// newest is the one we just created when adding
const created = res.endpoints.find(
(ep) => ep.base_url === form.baseUrl.trim(),
);
if (created)
recordTest(created.id, {
ok: true,
ms: formTest.ms,
version: formTest.version,
at: Date.now(),
});
}
toast.success(t("conn.saved"));
resetForm();
},
onError: (err) => {
toast.error(
t("conn.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function submitSave() {
showErrors = true;
if (!formValid || !formTest?.ok || $saveMutation.isPending) return;
$saveMutation.mutate();
}
// ------------------------------------------------------------------
// Card actions
// ------------------------------------------------------------------
async function testEndpoint(ep: ComfyEndpoint) {
pendingTests = { ...pendingTests, [ep.id]: true };
try {
const res = await comfy.testEndpoint({
endpoint_id: ep.id,
timeout_sec: 10,
});
if (res.ok) {
recordTest(ep.id, {
ok: true,
ms: res.elapsed_ms,
version: res.comfyui_version,
at: Date.now(),
});
} else {
recordTest(ep.id, {
ok: false,
at: Date.now(),
reason: res.message ?? res.detail ?? res.error ?? "unknown",
});
}
} catch (err) {
recordTest(ep.id, {
ok: false,
at: Date.now(),
reason: err instanceof Error ? err.message : String(err),
});
} finally {
const next = { ...pendingTests };
delete next[ep.id];
pendingTests = next;
}
}
let deleteOpen = $state(false);
let deleteTarget = $state<ComfyEndpoint | null>(null);
function requestDelete(ep: ComfyEndpoint) {
deleteTarget = ep;
deleteOpen = true;
}
function workflowsBound(id: string): number {
return ($wfMetaQuery.data ?? []).filter(
(wf) => wf.endpoint_id === id || (!wf.endpoint_id && id === activeId),
).length;
}
const deleteMessage = $derived.by(() => {
if (!deleteTarget) return "";
const n = workflowsBound(deleteTarget.id);
return n > 0
? t("conn.delConfirmUsed", { name: deleteTarget.name })
: t("conn.delConfirmFree", { name: deleteTarget.name });
});
const deleteMutation = createMutation({
mutationFn: () => {
if (!deleteTarget) throw new Error("no target");
return comfy.deleteEndpoint(deleteTarget.id);
},
onSuccess: () => {
if (deleteTarget) {
const next = { ...tests };
delete next[deleteTarget.id];
saveTests(next);
if (editingId === deleteTarget.id) resetForm();
}
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("conn.deleted"));
},
onError: (err) => {
toast.error(
t("conn.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
const defaultMutation = createMutation({
mutationFn: (id: string) => comfy.activateEndpoint(id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("conn.defaultSetToast"));
},
onError: (err) => {
toast.error(
t("conn.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function tokenTail(ep: ComfyEndpoint): string {
const tail = ep.token_tail ?? "";
return tail ? `****${tail}` : "";
}
loadTests();
$effect(() => {
// prune stale test records once the endpoint list is known
const data = $settingsQuery.data;
if (data) {
const keep = new Set(data.comfyui_endpoints.map((ep) => ep.id));
let changed = false;
const next: Record<string, TestRec> = {};
for (const [k, v] of Object.entries(tests)) {
if (keep.has(k)) next[k] = v;
else changed = true;
}
if (changed) saveTests(next);
}
});
// Probe every connection once per visit so the cards show real state
// instead of 「未测试」; results younger than a minute are reused.
const autoProbed = new Set<string>();
$effect(() => {
const list = endpoints;
untrack(() => {
const now = Date.now();
for (const ep of list) {
if (autoProbed.has(ep.id)) continue;
autoProbed.add(ep.id);
const rec = tests[ep.id];
if (!rec || now - rec.at > 60_000) void testEndpoint(ep);
}
});
});
</script>
{#if $settingsQuery.isLoading}<p class="mg-empty">
正在读取连接…
</p>{:else if $settingsQuery.isError}<p class="mg-empty" role="alert">
连接加载失败<Button onclick={() => $settingsQuery.refetch()}>重试</Button>
</p>{:else}
<div class="mg-cards">
{#each endpoints as ep (ep.id)}{@const rec = tests[ep.id]}
<article class="mg-card">
<div class="mg-row">
<span
class="mg-dot"
class:ok={rec?.ok === true}
class:err={rec?.ok === false}
></span><b>{ep.name}</b><span class="mg-grow"></span><span
class="mg-latency"
>{rec?.ms != null ? Math.round(rec.ms) + "ms" : "—"}</span
>
</div>
<div class="mg-mono">{ep.base_url}</div>
<div class="mg-muted">
{rec?.ok === false
? "连接失败 · " + (rec.reason ?? "原因未知")
: rec?.ok
? "连接测试通过"
: "尚未测试"} · 队列未知 · 显存未知
</div>
<div class="mg-row">
<span class="mg-tag">{workflowsBound(ep.id)} 个工作流使用</span><span
class="mg-grow"
></span><Button
size="sm"
loading={pendingTests[ep.id]}
onclick={() => testEndpoint(ep)}>测试连接</Button
><Button variant="ghost" size="sm" onclick={() => startEdit(ep)}
>编辑</Button
>
</div>
</article>
{/each}
<CloudModels />
<button class="mg-card pending mg-add" onclick={startAdd}
><Icon name="plus" size={22} />添加连接</button
>
</div>{/if}
<Modal
open={formMode !== "idle"}
title={formMode === "add" ? t("conn.formTitleAdd") : t("conn.formTitleEdit")}
dismissible={!formPending && !$saveMutation.isPending}
onclose={resetForm}
>
<aside
class="form-col"
class:open={formMode !== "idle"}
aria-label={t("conn.formTitleAdd")}
>
{#if formMode !== "idle"}
<div class="form-head">
<h2>
{formMode === "add"
? t("conn.formTitleAdd")
: t("conn.formTitleEdit")}
</h2>
</div>
<label class="f-field">
<span class="f-label">{t("conn.fName")}<em>*</em></span>
<input
class="f-input"
class:invalid={showErrors && formErrors.name}
value={form.name}
disabled={formPending}
oninput={(e) => (form.name = e.currentTarget.value)}
placeholder={t("conn.fNamePh")}
/>
{#if showErrors && formErrors.name}<span class="f-error"
>{formErrors.name}</span
>{/if}
</label>
<label class="f-field">
<span class="f-label">{t("conn.fBaseUrl")}<em>*</em></span>
<input
class="f-input mono"
class:invalid={showErrors && formErrors.baseUrl}
value={form.baseUrl}
disabled={formPending}
spellcheck="false"
oninput={(e) => {
form.baseUrl = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={t("conn.fBaseUrlPh")}
/>
{#if showErrors && formErrors.baseUrl}
<span class="f-error">{formErrors.baseUrl}</span>
{/if}
<span class="f-hint">{t("conn.baseUrlHelp")}</span>
</label>
<div class="f-field">
<span class="f-label">{t("conn.fAuth")}</span>
<div class="mg-seg" role="radiogroup" aria-label={t("conn.fAuth")}>
{#each [["none", t("conn.authNone")], ["bearer", t("conn.authBearer")], ["header", t("conn.authHeader")]] as [value, label] (value)}
<button
type="button"
class="seg-item"
class:on={form.authType === value}
aria-pressed={form.authType === value}
onclick={() => {
form.authType = value as typeof form.authType;
touchFormConnectivity();
}}
>
{label}
</button>
{/each}
</div>
<span class="f-hint">{t("conn.authHelp")}</span>
</div>
{#if form.authType !== "none"}
{#if form.authType === "header"}
<label class="f-field">
<span class="f-label">{t("conn.fHeaderName")}<em>*</em></span>
<input
class="f-input mono"
class:invalid={showErrors && formErrors.headerName}
value={form.headerName}
disabled={formPending}
spellcheck="false"
oninput={(e) => {
form.headerName = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={t("conn.fHeaderNamePh")}
/>
{#if showErrors && formErrors.headerName}
<span class="f-error">{formErrors.headerName}</span>
{/if}
</label>
{/if}
<label class="f-field">
<span class="f-label">
{t("conn.fToken")}
<span class="kh-inline mono">{t("conn.keyNoEcho")}</span>
</span>
<span class="key-wrap">
<input
class="f-input key-input"
type="password"
value={form.token}
disabled={formPending}
autocomplete="off"
oninput={(e) => {
form.token = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={formMode === "edit" && editingEndpoint?.has_token
? t("conn.tokenSaved")
: "…"}
/>
{#if formMode === "edit" && editingEndpoint?.has_token && tokenTail(editingEndpoint)}
<span class="kh mono">{tokenTail(editingEndpoint)}</span>
{/if}
</span>
<span class="f-hint">{t("conn.keyHelp")}</span>
</label>
{/if}
<label class="f-field">
<span class="f-label">{t("conn.fNotes")}</span>
<input
class="f-input"
value={form.notes}
disabled={formPending}
oninput={(e) => (form.notes = e.currentTarget.value)}
placeholder={t("conn.fNotesPh")}
/>
</label>
{#if formTest?.ok}
<div class="result-bar ok" transition:fade={{ duration: 250 }}>
{t("conn.testOk", {
ms: formTest.ms ?? "?",
v: formTest.version ?? "?",
})}
</div>
{:else if formTest?.ok === false && formTestFailure}
<div class="result-bar err" transition:fade={{ duration: 250 }}>
<p>{t("conn.testFail", { reason: formTestFailure.message })}</p>
{#if formTestFailure.detail}
<p class="mono detail">{formTestFailure.detail}</p>
{/if}
</div>
{/if}
<div class="form-actions">
<span class="need-test" class:passed={formTest?.ok}
>{t("conn.needTest")}</span
>
<Button
variant="secondary"
size="sm"
loading={formPending}
onclick={runTest}
>
{formPending ? t("conn.testing") : t("conn.testBtn")}
</Button>
<Button
variant="primary"
size="sm"
disabled={!formValid || !formTest?.ok}
loading={$saveMutation.isPending}
onclick={submitSave}
>
{t("conn.save")}
</Button>
</div>
{/if}
{#if formMode === "edit" && editingId}<div class="mg-row">
{#if editingId !== activeId}<Button
size="sm"
onclick={() => $defaultMutation.mutate(editingId!)}>设为默认</Button
>{/if}<Button
size="sm"
variant="danger"
onclick={() => {
const target = editingEndpoint;
if (target) requestDelete(target);
}}>删除</Button
>
</div>{/if}
</aside></Modal
>
<ConfirmDialog
bind:open={deleteOpen}
title={t("conn.delTitle")}
message={deleteMessage}
confirmLabel={t("conn.delete")}
danger
onconfirm={() => $deleteMutation.mutate()}
oncancel={() => (deleteTarget = null)}
/>
@@ -0,0 +1,54 @@
<script lang="ts">
let {
model,
muted = false,
large = false,
}: { model: string | null; muted?: boolean; large?: boolean } = $props();
const family = $derived.by(() => {
const value = (model ?? "").toLowerCase();
const known: [string, string, string][] = [
["qwen", "qwen", "Qwen"],
["krea", "krea", "Kr2"],
["flux", "flux", "FLX"],
["z-image", "zimg", "Z"],
["minimax", "h3", "H3"],
["wan", "wan", "Wan"],
["anima", "anima", "Ani"],
];
const hit = known.find(([prefix]) => value.includes(prefix));
return hit
? { color: `var(--family-${hit[1]})`, label: hit[2] }
: { color: "var(--tx-3)", label: model?.slice(0, 3) ?? "本地" };
});
</script>
<span
class="family-badge"
class:muted
class:large
style:--family-color={family.color}
aria-hidden="true">{family.label}</span
>
<style>
.family-badge {
width: 26px;
height: 26px;
border-radius: 7px;
display: grid;
place-items: center;
flex: none;
font: 700 9px var(--mono);
color: var(--family-ink);
background: var(--family-color);
}
.muted {
filter: grayscale(1) brightness(0.55);
}
.large {
width: 36px;
height: 36px;
border-radius: 9px;
font-size: 11px;
}
</style>
@@ -0,0 +1,833 @@
<script lang="ts">
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { fade } from "svelte/transition";
import Modal from "$lib/components/ui/Modal.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 { settings, type LlmProfile, type Settings } from "$lib/api";
import { randomId } from "$lib/ids";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
const client = useQueryClient();
const settingsQuery = createQuery({
queryKey: ["settings"],
queryFn: settings.get,
});
// A profile while being edited here: `api_key` is the public boolean,
// except for a freshly typed key waiting to be sent to the server.
type WorkingProfile = Omit<LlmProfile, "api_key"> & {
api_key: boolean | string;
};
// ------------------------------------------------------------------
// Local test-state persistence (last result / time / consecutive
// failures per profile). Purely presentational; connectivity facts
// always come from the server-side probe.
// ------------------------------------------------------------------
type TestRec = {
ok: boolean;
ms?: number;
at: number;
fails: number;
reason?: string;
};
const TESTS_KEY = "calliope.models.tests";
let tests = $state<Record<string, TestRec>>({});
let pendingTests = $state<Record<string, boolean>>({});
function loadTests() {
try {
const raw = localStorage.getItem(TESTS_KEY);
if (raw) tests = JSON.parse(raw) as Record<string, TestRec>;
} catch {
tests = {};
}
}
function saveTests(map: Record<string, TestRec>) {
tests = map;
try {
localStorage.setItem(TESTS_KEY, JSON.stringify(map));
} catch {
/* storage full / disabled — the page still works without persistence */
}
}
function recordTest(id: string, rec: TestRec) {
saveTests({ ...tests, [id]: rec });
}
function pruneTests(ids: string[]) {
const keep = new Set(ids);
const next: Record<string, TestRec> = {};
let changed = false;
for (const [k, v] of Object.entries(tests)) {
if (keep.has(k)) next[k] = v;
else changed = true;
}
if (changed) saveTests(next);
}
// ------------------------------------------------------------------
// Form state — add / edit custom LLM (spec §9.3)
// ------------------------------------------------------------------
type FormMode = "idle" | "add" | "edit";
let formMode = $state<FormMode>("idle");
let editingId = $state<string | null>(null);
let form = $state({
name: "",
baseUrl: "",
model: "",
apiKey: "",
timeout: 10,
});
let formTest = $state<{ ok: boolean; ms?: number } | null>(null);
let formTestFailure = $state<{ text: string; detail: string } | null>(null);
let formPending = $state(false);
let showErrors = $state(false);
const TIMEOUT_OPTIONS = [5, 10, 15, 30, 60];
function resetForm() {
formMode = "idle";
editingId = null;
form = { name: "", baseUrl: "", model: "", apiKey: "", timeout: 10 };
formTest = null;
formTestFailure = null;
showErrors = false;
}
export function startAdd() {
resetForm();
formMode = "add";
}
function startEdit(profile: LlmProfile) {
resetForm();
formMode = "edit";
editingId = profile.id;
form = {
name: profile.name,
baseUrl: profile.base_url,
model: profile.model,
apiKey: "",
timeout: 10,
};
}
// A connectivity-relevant edit invalidates the previous passing test.
function touchFormConnectivity() {
formTest = null;
formTestFailure = null;
}
const urlValid = $derived.by(() => {
try {
const u = new URL(form.baseUrl.trim());
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
});
const formErrors = $derived.by(() => {
const errs: Record<string, string> = {};
if (!form.name.trim()) errs.name = t("models.errName");
if (!urlValid) errs.baseUrl = t("models.errUrl");
if (!form.model.trim()) errs.model = t("models.errModel");
// Key is required when creating; on edit an empty field keeps the
// stored key (masked tail placeholder covers the affordance).
if (formMode === "add" && !form.apiKey.trim())
errs.apiKey = t("models.errKey");
return errs;
});
const formValid = $derived(Object.keys(formErrors).length === 0);
const editingProfile = $derived.by(() => {
if (formMode !== "edit" || !editingId || !$settingsQuery.data) return null;
return (
$settingsQuery.data.llm_profiles.find((p) => p.id === editingId) ?? null
);
});
// ------------------------------------------------------------------
// Failure classification → spec §9.6 copy (primary sentence) with the
// server-provided detail as a mono secondary line where it adds info.
// ------------------------------------------------------------------
function classifyFailure(
res: { error?: string; detail?: string; timeout_sec?: number },
fallbackSec: number,
): { text: string; detail: string } {
const detail = res.detail ?? "";
switch (res.error) {
case "timeout":
return {
text: t("models.failTimeout", {
sec: res.timeout_sec ?? fallbackSec,
}),
detail,
};
case "auth":
return { text: t("models.failAuth"), detail };
case "not_found":
return { text: t("models.failNotFound"), detail };
case "http": {
if (detail.startsWith("HTTP 404"))
return { text: t("models.failNotOpenai"), detail };
const status = detail.split(":")[0].replace("HTTP ", "").trim();
return {
text: t("models.failHttp", { status: status || detail }),
detail,
};
}
case "connect":
default:
return { text: t("models.failConnect"), detail };
}
}
async function runTest() {
showErrors = true;
if (!urlValid || !form.model.trim()) return;
// On add the key is required before anything can be probed.
if (formMode === "add" && !form.apiKey.trim()) return;
formPending = true;
formTest = null;
formTestFailure = null;
try {
const res = await settings.testLlm({
...(formMode === "edit" && editingId ? { profile_id: editingId } : {}),
base_url: form.baseUrl.trim(),
model: form.model.trim(),
...(form.apiKey.trim() ? { api_key: form.apiKey.trim() } : {}),
timeout_sec: form.timeout,
});
if (res.ok) {
formTest = { ok: true, ms: res.elapsed_ms };
} else {
formTest = { ok: false };
formTestFailure = classifyFailure(res, form.timeout);
}
} catch (err) {
formTest = { ok: false };
formTestFailure = {
text: t("models.failConnect"),
detail: err instanceof Error ? err.message : String(err),
};
} finally {
formPending = false;
}
}
function llmProfilesFromSettings(s: Settings): WorkingProfile[] {
if (s.llm_profiles?.length) return s.llm_profiles.map((p) => ({ ...p }));
return [
{
id: s.llm_active_id || "legacy",
name: s.llm_model || "LLM",
base_url: s.llm_base_url,
model: s.llm_model,
api_key: s.llm_api_key,
},
];
}
const saveMutation = createMutation({
mutationFn: async () => {
const s = $settingsQuery.data;
if (!s) throw new Error("settings unavailable");
const base = llmProfilesFromSettings(s);
const typedKey = form.apiKey.trim();
let list: WorkingProfile[];
if (formMode === "edit" && editingId) {
list = base.map((p) =>
p.id === editingId
? {
...p,
name: form.name.trim(),
base_url: form.baseUrl.trim(),
model: form.model.trim(),
...(typedKey ? { api_key: typedKey } : {}),
}
: p,
);
} else {
list = [
...base,
{
id: randomId(),
name: form.name.trim(),
base_url: form.baseUrl.trim(),
model: form.model.trim(),
api_key: typedKey,
},
];
}
return settings.update({
llm_profiles: list.map((p) => ({
id: p.id,
name: p.name.trim(),
base_url: p.base_url.trim(),
model: p.model.trim(),
...(typeof p.api_key === "string" && p.api_key.trim()
? { api_key: p.api_key.trim() }
: {}),
})),
});
},
onSuccess: (saved) => {
client.invalidateQueries({ queryKey: ["settings"] });
// Carry the passing test over so the new card shows 已连接.
if (formTest?.ok) {
const id =
formMode === "edit" && editingId
? editingId
: (saved.llm_profiles.at(-1)?.id ?? "");
if (id)
recordTest(id, {
ok: true,
ms: formTest.ms,
at: Date.now(),
fails: 0,
});
}
toast.success(t("models.saved"));
resetForm();
},
onError: (err) => {
toast.error(
t("models.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function submitSave() {
showErrors = true;
if (!formValid || !formTest?.ok || $saveMutation.isPending) return;
$saveMutation.mutate();
}
// ------------------------------------------------------------------
// Card actions: per-profile test / delete / set default
// ------------------------------------------------------------------
async function testProfile(profile: LlmProfile) {
pendingTests = { ...pendingTests, [profile.id]: true };
try {
const res = await settings.testLlm({
profile_id: profile.id,
base_url: profile.base_url.trim(),
model: profile.model.trim(),
timeout_sec: 10,
});
const prev = tests[profile.id];
if (res.ok) {
recordTest(profile.id, {
ok: true,
ms: res.elapsed_ms,
at: Date.now(),
fails: 0,
});
} else {
const cls = classifyFailure(res, 10);
recordTest(profile.id, {
ok: false,
at: Date.now(),
fails: (prev?.fails ?? 0) + 1,
reason: cls.text + (cls.detail ? `(${cls.detail})` : ""),
});
}
} catch (err) {
const prev = tests[profile.id];
recordTest(profile.id, {
ok: false,
at: Date.now(),
fails: (prev?.fails ?? 0) + 1,
reason: err instanceof Error ? err.message : String(err),
});
} finally {
const next = { ...pendingTests };
delete next[profile.id];
pendingTests = next;
}
}
let deleteOpen = $state(false);
let deleteTarget = $state<LlmProfile | null>(null);
function requestDelete(profile: LlmProfile) {
deleteTarget = profile;
deleteOpen = true;
}
function agentsUsing(s: Settings, profileId: string): number {
return Object.values(s.agent_llm_assignments ?? {}).filter(
(v) => v === profileId,
).length;
}
const deleteMessage = $derived.by(() => {
const s = $settingsQuery.data;
if (!deleteTarget || !s) return "";
const n = agentsUsing(s, deleteTarget.id);
return n > 0
? t("models.delConfirmUsed", { n })
: t("models.delConfirmFree");
});
const deleteMutation = createMutation({
mutationFn: async () => {
const s = $settingsQuery.data;
const target = deleteTarget;
if (!s || !target) throw new Error("no target");
const list = llmProfilesFromSettings(s).filter((p) => p.id !== target.id);
return settings.update({
llm_profiles: list.map((p) => ({
id: p.id,
name: p.name.trim(),
base_url: p.base_url.trim(),
model: p.model.trim(),
})),
});
},
onSuccess: () => {
if (deleteTarget) {
const next = { ...tests };
delete next[deleteTarget.id];
saveTests(next);
if (editingId === deleteTarget.id) resetForm();
}
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("models.deleted"));
},
onError: (err) => {
toast.error(
t("models.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
const defaultMutation = createMutation({
mutationFn: (id: string) => settings.update({ llm_active_id: id }),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("models.defaultSetToast"));
},
onError: (err) => {
toast.error(
t("models.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
// ------------------------------------------------------------------
// Agent default-model panel (spec §9.4). The five AIGC agents; 视觉设计
// binds to the `style` role added for the upcoming style agent.
// ------------------------------------------------------------------
const AGENTS: {
role: string;
labelKey: string;
color: string;
char: string;
dark?: boolean;
}[] = [
{
role: "story",
labelKey: "models.agentStory",
color: "var(--avatar-1)",
char: "剧",
dark: true,
},
{
role: "style",
labelKey: "models.agentStyle",
color: "var(--avatar-2)",
char: "视",
dark: true,
},
{
role: "script",
labelKey: "models.agentScript",
color: "var(--avatar-3)",
char: "分",
},
{
role: "assets",
labelKey: "models.agentAssets",
color: "var(--avatar-4)",
char: "素",
},
{
role: "video",
labelKey: "models.agentVideo",
color: "var(--avatar-5)",
char: "镜",
},
];
const assignMutation = createMutation({
mutationFn: (payload: Record<string, string | null>) =>
settings.update({ agent_llm_assignments: payload }),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("models.assignSaved"));
},
onError: (err) => {
client.invalidateQueries({ queryKey: ["settings"] });
toast.error(
t("models.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function setAssignment(role: string, value: string) {
// Single-role delta only (HEL-619): replaying the whole cached
// snapshot races concurrent dropdown switches — an older snapshot
// could overwrite a newer choice. The server merges per role.
$assignMutation.mutate({ [role]: value === "" ? null : value });
}
function keyTail(profile: LlmProfile): string {
const tail = profile.api_key_tail ?? "";
return tail ? `sk-****${tail}` : "";
}
loadTests();
// Prune stale test records once the profile list is known.
$effect(() => {
const data = $settingsQuery.data;
if (data) pruneTests(data.llm_profiles.map((p) => p.id));
});
// Test each model once per visit (1-token probe) so the cards show real
// state instead of 「未测试」; results younger than 10 minutes are reused.
const autoProbed = new Set<string>();
$effect(() => {
const list = $settingsQuery.data?.llm_profiles ?? [];
untrack(() => {
const now = Date.now();
for (const p of list) {
if (autoProbed.has(p.id)) continue;
autoProbed.add(p.id);
const rec = tests[p.id];
if (!rec || now - rec.at > 600_000) void testProfile(p);
}
});
});
</script>
{#if $settingsQuery.isLoading}<p class="mg-empty">
正在读取模型…
</p>{:else if !$settingsQuery.data}<p class="mg-empty" role="alert">
模型加载失败<Button onclick={() => $settingsQuery.refetch()}>重试</Button>
</p>{:else}{@const s = $settingsQuery.data}{@const profiles = s.llm_profiles}
<div class="mg-cards">
{#each profiles as profile (profile.id)}{@const rec = tests[profile.id]}
<article class="mg-card">
<div class="mg-row">
<span
class="mg-dot"
class:ok={rec?.ok === true}
class:err={rec?.ok === false}
></span><b>{profile.name}</b><span class="mg-grow"></span><span
class="mg-latency"
>{rec?.ms != null ? Math.round(rec.ms) + "ms" : "—"}</span
>
</div>
<div class="mg-mono">{profile.base_url}</div>
<div class="mg-muted">{profile.model}</div>
<div class="mg-dim">
{rec?.ok === false
? (rec.reason ?? "连接失败")
: rec?.ok
? "连接测试通过"
: "尚未测试"}
</div>
<div class="mg-row">
<span class="mg-tag">{agentsUsing(s, profile.id)} 个智能体使用</span
><span class="mg-grow"></span><Button
size="sm"
loading={pendingTests[profile.id]}
onclick={() => testProfile(profile)}>测试连接</Button
><Button size="sm" variant="ghost" onclick={() => startEdit(profile)}
>编辑</Button
>
</div>
</article>{/each}<button class="mg-card pending mg-add" onclick={startAdd}
><Icon name="plus" size={22} />添加大语言模型</button
>
<div class="model-assignments">
<section class="mg-field">
<div class="mg-row">
<h2>{t("models.agentsTitle")}</h2>
<span class="panel-hint">{t("models.agentsHint")}</span>
</div>
<div class="mg-assignments">
{#each AGENTS as agent (agent.role)}
<label class="agent-cell">
<span
class="avatar"
style:background={agent.color}
class:dark={agent.dark}
>
{agent.char}
</span>
<span class="agent-name">{t(agent.labelKey)}</span>
<select
class="a-select"
value={s.agent_llm_assignments?.[agent.role] ?? ""}
onchange={(e) =>
setAssignment(agent.role, e.currentTarget.value)}
>
<option value="">{t("models.agentDefault")}</option>
{#each profiles as p (p.id)}
<option
value={p.id}
class:unavailable={tests[p.id]?.ok === false}
>
{p.name} — {p.model}{tests[p.id]?.ok === false
? t("models.unavailable")
: ""}
</option>
{/each}
</select>
</label>
{/each}
</div>
</section>
</div>
</div>{/if}
<Modal
open={formMode !== "idle"}
title={formMode === "edit"
? t("models.formTitleEdit")
: t("models.formTitleAdd")}
dismissible={!formPending}
onclose={resetForm}
size="md"
><aside
class="form-col"
class:open={formMode !== "idle"}
aria-label={t("models.formTitleAdd")}
>
{#if formMode === "idle"}
<div class="form-idle">
<Icon name="sparkle" size={20} />
<p>{t("models.idleFormHint")}</p>
</div>
{:else}
<div class="form-head">
<h2>
{formMode === "add"
? t("models.formTitleAdd")
: t("models.formTitleEdit")}
</h2>
<button
type="button"
class="icon-btn"
aria-label={t("models.formClose")}
onclick={resetForm}
>
<Icon name="close" size={16} />
</button>
</div>
<label class="f-field">
<span class="f-label">{t("models.fName")}<em>*</em></span>
<input
class="f-input"
class:invalid={showErrors && formErrors.name}
value={form.name}
disabled={formPending}
oninput={(e) => (form.name = e.currentTarget.value)}
placeholder={t("models.fNamePh")}
/>
{#if showErrors && formErrors.name}<span class="f-error"
>{formErrors.name}</span
>{/if}
</label>
<div class="f-field">
<span class="f-label">{t("models.fApiType")}</span>
<div class="seg" role="radiogroup" aria-label={t("models.fApiType")}>
<span class="seg-item selected" role="radio" aria-checked="true">
{t("models.apiOpenai")}
</span>
<span class="seg-item disabled" aria-disabled="true">
{t("models.apiAnthropic")}
</span>
<span class="seg-item disabled" aria-disabled="true"
>{t("models.apiOther")}</span
>
</div>
<span class="f-hint">{t("models.apiTypeHelp")}</span>
</div>
<label class="f-field">
<span class="f-label">{t("models.fBaseUrl")}<em>*</em></span>
<input
class="f-input mono"
class:invalid={showErrors && formErrors.baseUrl}
value={form.baseUrl}
disabled={formPending}
spellcheck="false"
oninput={(e) => {
form.baseUrl = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder="http://192.168.200.18:11434/v1"
/>
{#if showErrors && formErrors.baseUrl}
<span class="f-error">{formErrors.baseUrl}</span>
{/if}
<span class="f-hint">{t("models.baseUrlHelp")}</span>
</label>
<label class="f-field">
<span class="f-label">{t("models.fModel")}<em>*</em></span>
<input
class="f-input mono"
class:invalid={showErrors && formErrors.model}
value={form.model}
disabled={formPending}
spellcheck="false"
oninput={(e) => {
form.model = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder="deepseek-chat"
/>
{#if showErrors && formErrors.model}
<span class="f-error">{formErrors.model}</span>
{/if}
<span class="f-hint">{t("models.modelHelp")}</span>
</label>
<label class="f-field">
<span class="f-label">
{t("models.fApiKey")}<em>*</em>
<span class="kh-inline mono">{t("models.keyNoEcho")}</span>
</span>
<span class="key-wrap">
<input
class="f-input key-input"
type="password"
class:invalid={showErrors && formErrors.apiKey}
value={form.apiKey}
disabled={formPending}
autocomplete="off"
oninput={(e) => {
form.apiKey = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={formMode === "edit" && editingProfile?.api_key
? t("models.keyKeepHint")
: "sk-…"}
/>
{#if formMode === "edit" && editingProfile?.api_key && keyTail(editingProfile)}
<span class="kh mono">{keyTail(editingProfile)}</span>
{/if}
</span>
{#if showErrors && formErrors.apiKey}
<span class="f-error">{formErrors.apiKey}</span>
{/if}
<span class="f-hint">{t("models.keyHelp")}</span>
</label>
<label class="f-field">
<span class="f-label">{t("models.fTimeout")}</span>
<select
class="f-input"
value={form.timeout}
disabled={formPending}
onchange={(e) => {
form.timeout = Number(e.currentTarget.value);
touchFormConnectivity();
}}
>
{#each TIMEOUT_OPTIONS as sec (sec)}
<option value={sec}>{t("models.timeoutSec", { n: sec })}</option>
{/each}
</select>
</label>
{#if formTest?.ok}
<div class="result-bar ok" transition:fade={{ duration: 250 }}>
{t("models.testOk", {
ms: formTest.ms ?? "?",
model: form.model.trim(),
})}
</div>
{:else if formTest?.ok === false && formTestFailure}
<div class="result-bar err" transition:fade={{ duration: 250 }}>
<p>
{t("models.testFail", { reason: formTestFailure.text })}
</p>
{#if formTestFailure.detail}
<p class="mono detail">{formTestFailure.detail}</p>
{/if}
</div>
{/if}
<div class="form-actions">
<span class="need-test" class:passed={formTest?.ok}
>{t("models.needTest")}</span
>
<Button
variant="secondary"
size="sm"
loading={formPending}
onclick={runTest}
>
{formPending ? t("models.testing") : t("models.testBtn")}
</Button>
<Button
variant="primary"
size="sm"
disabled={!formValid || !formTest?.ok}
loading={$saveMutation.isPending}
onclick={submitSave}
>
{t("models.save")}
</Button>
</div>
{/if}
{#if formMode === "edit" && editingId}<div class="mg-row">
{#if editingId !== $settingsQuery.data?.llm_active_id}<Button
size="sm"
onclick={() => $defaultMutation.mutate(editingId!)}>设为默认</Button
>{/if}<Button
size="sm"
variant="danger"
onclick={() => {
const target = editingProfile;
if (target) requestDelete(target);
}}>删除</Button
>
</div>{/if}
</aside></Modal
>
<ConfirmDialog
bind:open={deleteOpen}
title={t("models.delTitle")}
message={deleteMessage}
confirmLabel={t("models.delete")}
danger
onconfirm={() => $deleteMutation.mutate()}
oncancel={() => (deleteTarget = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from "svelte";
import AppShell from "$lib/components/shell/AppShell.svelte";
import type { NavId } from "$lib/components/shell/nav";
import "./management.css";
let {
title,
active,
tools,
actions,
children,
scene = "manage",
}: {
scene?: "manage" | "gold";
title: string;
active: NavId;
tools?: Snippet;
actions?: Snippet;
children: Snippet;
} = $props();
</script>
<AppShell {title} {active} workspace>
{#snippet pageHeader()}
<header
class="management-header"
style:--scene={scene === "gold" ? "var(--gold)" : "var(--sc-manage)"}
>
<b>{title}</b>{@render tools?.()}<span class="mg-grow"
></span>{@render actions?.()}
</header>
{/snippet}
<section
class="management-page"
aria-label={title}
style:--scene={scene === "gold" ? "var(--gold)" : "var(--sc-manage)"}
>
{@render children()}
</section>
</AppShell>
@@ -0,0 +1,754 @@
/* HEL-726 approved management primitives. Geometry and colours use theme tokens. */
.management-header {
--icon-stroke: var(--manage-icon-stroke);
letter-spacing: var(--manage-header-letter-spacing);
box-sizing: border-box;
--button-weight: var(--manage-control-weight);
--button-primary-weight: var(--manage-control-weight);
--button-height: var(--manage-control-height);
--button-font-size: var(--manage-control-size);
--button-radius: var(--manage-control-radius);
--button-secondary-bg: var(--bg-3);
--button-sm-height: var(--manage-button-height);
--button-sm-padding: var(--manage-button-padding);
--button-secondary-radius: var(--manage-button-radius);
height: var(--head);
flex: none;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid var(--line);
background: var(--glass);
backdrop-filter: blur(10px);
position: relative;
z-index: 3;
min-width: 0;
color: var(--tx-1);
}
.management-header > b {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.management-page {
--icon-stroke: var(--manage-icon-stroke);
box-sizing: border-box;
--button-weight: var(--manage-control-weight);
--button-primary-weight: var(--manage-control-weight);
--button-height: var(--manage-control-height);
--button-font-size: var(--manage-control-size);
--button-radius: var(--manage-control-radius);
--button-secondary-bg: var(--bg-3);
animation: management-enter var(--t-slow) var(--ease-out);
--button-sm-height: var(--manage-button-height);
--button-sm-padding: var(--manage-button-padding);
--button-sm-radius: var(--manage-button-radius);
--button-primary-height: var(--manage-control-height);
--button-primary-padding: 12px;
--button-primary-radius: var(--manage-control-radius);
--button-secondary-radius: var(--manage-button-radius);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
color: var(--tx-1);
font-size: 13px;
overflow: hidden;
}
.mg-grow {
flex: 1;
min-width: 0;
}
.manage {
--scene: var(--sc-manage);
}
.mg-layout {
box-sizing: border-box;
flex: 1;
display: grid;
grid-template-columns: var(--manage-filter-width) minmax(0, 1fr) var(
--manage-detail-width
);
min-height: 0;
}
.mg-side {
box-sizing: border-box;
border-right: 1px solid var(--line);
background: var(--manage-panel-background);
padding: 12px 10px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.mg-side .mg-label {
font-size: 11px;
color: var(--tx-3);
padding: 10px 8px 4px;
letter-spacing: 0.5px;
}
.mg-filter {
border: 0;
background: none;
text-align: left;
flex: none;
font-family: inherit;
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px;
border-radius: 8px;
font-size: 12.5px;
color: var(--tx-2);
cursor: pointer;
}
.mg-filter:hover {
background: rgb(var(--ink) / 0.04);
color: var(--tx-1);
}
.mg-filter.on {
background: var(--bg-3);
color: var(--tx-1);
}
.mg-filter .n {
margin-left: auto;
font: 500 11px var(--mono);
color: var(--tx-3);
}
.mg-main {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.mg-tools {
box-sizing: border-box;
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.mg-table {
flex: 1;
overflow: auto;
}
.mg-table table {
table-layout: fixed;
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.mg-table th {
position: sticky;
top: 0;
background: var(--bg-0);
z-index: 1;
text-align: left;
font-weight: 500;
color: var(--tx-3);
font-size: 11.5px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
.mg-table td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.mg-table tr {
cursor: pointer;
transition: background var(--t-fast);
}
.mg-table tbody tr:hover {
background: rgb(var(--ink) / 0.025);
}
.mg-table tr.on {
background: color-mix(in srgb, var(--sc-manage) 10%, transparent);
}
.mg-table .nm b {
font-weight: 500;
display: block;
}
.mg-table .nm small {
font: 11px var(--mono);
color: var(--tx-3);
}
.mg-detail {
box-sizing: border-box;
min-width: 0;
border-left: 1px solid var(--line);
background: var(--manage-panel-background);
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mg-map {
display: grid;
grid-template-columns: 1fr 18px 1fr;
gap: 6px;
align-items: center;
font-size: 12px;
}
.mg-map .a {
padding: 6px 8px;
border-radius: 6px;
background: var(--bg-0);
border: 1px solid var(--line);
font-family: var(--mono);
font-size: 11px;
color: var(--tx-2);
}
.mg-map .b {
padding: 6px 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--sc-image) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--sc-image) 30%, transparent);
}
.mg-media {
flex: 1;
overflow: auto;
padding: 14px 16px;
columns: 5 200px;
column-gap: 10px;
}
.mg-media > article {
break-inside: avoid;
margin-bottom: 10px;
border-radius: var(--manage-media-radius);
overflow: hidden;
border: 1px solid var(--line);
position: relative;
}
.mg-media img {
display: block;
width: 100%;
}
.mg-cards {
flex: 1;
overflow: auto;
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--manage-card-min), 1fr));
gap: 12px;
align-content: start;
grid-auto-rows: max-content;
}
.mg-card {
box-sizing: border-box;
min-width: 0;
border-radius: var(--manage-card-radius);
border: 1px solid var(--line-2);
background: var(--bg-2);
padding: var(--manage-card-padding);
display: flex;
flex-direction: column;
gap: 10px;
}
.mg-card .mg-row b {
font-size: 13.5px;
}
.mg-latency {
font: 600 11px var(--mono);
color: var(--ok);
}
.mg-filter small {
margin-left: auto;
font: 500 11px var(--mono);
color: var(--tx-3);
}
.mg-search {
height: 34px;
border-radius: var(--r-sm);
background: var(--well);
border: 1px solid var(--line-2);
padding: 0 10px;
display: flex;
align-items: center;
gap: 8px;
color: var(--tx-3);
min-width: 0;
box-sizing: border-box;
}
.mg-search input {
min-width: 0;
width: 100%;
background: transparent;
border: 0;
outline: 0;
color: var(--tx-1);
font: inherit;
font-size: 13px;
padding: 0;
}
.mg-search:focus-within {
border-color: var(--scene);
}
.mg-search kbd {
font: 10px var(--mono);
color: var(--tx-3);
}
.mg-seg {
box-sizing: border-box;
display: flex;
padding: 3px;
border-radius: var(--manage-segment-radius);
background: var(--well);
border: 1px solid var(--line);
gap: 2px;
}
.mg-seg button {
flex: 1;
white-space: nowrap;
height: 28px;
border: 0;
border-radius: var(--manage-segment-option-radius);
background: transparent;
color: var(--tx-2);
font: inherit;
font-size: 12.5px;
padding: 0;
cursor: pointer;
transition: all var(--t-base) var(--ease-out);
}
.mg-seg button.on {
background: var(--bg-3);
color: var(--tx-1);
box-shadow: var(--sh-1);
}
.mg-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.mg-muted {
color: var(--tx-2);
}
.mg-dim {
color: var(--tx-3);
font-size: 12px;
}
.mg-mono {
font: 11px/var(--lh-body) var(--mono);
color: var(--tx-3);
overflow-wrap: anywhere;
}
.mg-tag {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
height: 18px;
padding: 0 6px;
border-radius: 4px;
border: 1px solid var(--line);
background: rgb(var(--ink) / 0.05);
font-size: 11px;
color: var(--tx-2);
}
.mg-tag.ok {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
background: color-mix(in srgb, var(--ok) 7%, transparent);
}
.mg-tag.warn {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 30%, transparent);
background: color-mix(in srgb, var(--warn) 7%, transparent);
}
.mg-tag.err {
color: var(--err);
border-color: color-mix(in srgb, var(--err) 30%, transparent);
background: color-mix(in srgb, var(--err) 7%, transparent);
}
.mg-field {
box-sizing: border-box;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--manage-field-radius);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.mg-field h3 {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--tx-2);
margin: 0;
}
.mg-detail h2 {
font-size: 14px;
margin: 0;
font-weight: 600;
}
.mg-name {
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mg-name b {
font-size: 12.5px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mg-name small {
font: 11px var(--mono);
color: var(--tx-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mg-empty {
padding: 40px 16px;
text-align: center;
color: var(--tx-3);
font-size: 12px;
}
.mg-card.pending {
border-style: dashed;
background: transparent;
}
.mg-add {
min-height: var(--manage-add-min-height);
display: grid;
place-items: center;
color: var(--tx-3);
cursor: pointer;
font: inherit;
}
.mg-dot {
height: 7px;
width: 7px;
border-radius: 50%;
background: var(--tx-3);
flex: none;
}
.mg-dot.ok {
background: var(--ok);
}
.mg-dot.err {
background: var(--err);
}
.mg-click {
border: 0;
background: none;
text-align: left;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
width: 100%;
}
.management-page button:focus-visible,
.management-header button:focus-visible {
outline: 2px solid var(--scene, var(--info));
outline-offset: 2px;
}
/* Shared connection/model edit form, hosted by the existing focus-trapping Modal. */
.form-col {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.form-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-head h2 {
font-size: 14px;
margin: 0;
}
.f-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--tx-2);
}
.f-label {
font-weight: 500;
display: flex;
gap: 4px;
}
.f-label em {
color: var(--err);
}
.f-input {
height: 34px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--well);
padding: 0 10px;
color: var(--tx-1);
font: inherit;
width: 100%;
box-sizing: border-box;
}
textarea.f-input {
height: 70px;
padding: 8px 10px;
resize: vertical;
}
.f-input.invalid {
border-color: var(--err);
}
.f-input:focus {
outline: 2px solid var(--info);
outline-offset: 1px;
}
.f-error {
color: var(--err);
font-size: 11px;
}
.f-hint,
.need-test {
color: var(--tx-3);
font-size: 11px;
}
.need-test.passed {
color: var(--ok);
}
.form-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.form-test-result,
.form-test-failure {
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
font-size: 12px;
}
.form-test-failure {
color: var(--err);
}
.form-col pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 11px var(--mono);
}
.mg-assignments {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.agent-cell {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
font-size: 12px;
}
.agent-cell .avatar {
display: none;
}
.model-assignments {
grid-column: 1/-1;
}
.wf-map-editor {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
border: 1px dashed var(--line-2);
border-radius: var(--r-sm);
}
.wf-slot-row {
display: flex;
gap: 8px;
align-items: center;
}
.wf-out-check {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.wf-slot-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.wf-slot-id,
.wf-map-hint {
font-size: 11px;
color: var(--tx-3);
}
.wf-slot-row select {
width: 90px;
}
.result-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.result-bar.ok {
color: var(--ok);
}
.result-bar.err {
color: var(--err);
}
.result-bar .detail {
font-size: 11px;
overflow-wrap: anywhere;
}
.key-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.kh,
.kh-inline {
font: 11px var(--mono);
color: var(--tx-3);
}
.mg-table .mg-row {
flex-wrap: nowrap;
}
.mg-field p {
margin: 0;
}
.mg-card > .mg-row {
flex-wrap: nowrap;
}
.mg-card > .mg-row > b {
min-width: 0;
}
.mg-options {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.mg-option {
box-sizing: border-box;
height: 28px;
padding: 0 10px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
font: inherit;
font-size: 12px;
color: var(--tx-2);
background: transparent;
cursor: pointer;
transition: all var(--t-fast);
}
.mg-option.on {
background: color-mix(in srgb, var(--sc-manage) 12%, var(--bg-3));
border-color: color-mix(in srgb, var(--sc-manage) 60%, transparent);
color: var(--tx-1);
}
.mg-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mg-step {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 4px;
width: 114px;
height: 28px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--bg-0);
}
.mg-step button {
width: 24px;
height: 26px;
border: 0;
color: var(--tx-2);
background: transparent;
cursor: pointer;
}
.mg-step input {
width: 50px;
padding: 0;
border: 0;
background: transparent;
color: var(--tx-1);
font: 600 12px var(--mono);
text-align: center;
appearance: textfield;
-moz-appearance: textfield;
}
.mg-step input::-webkit-inner-spin-button {
appearance: none;
}
@keyframes management-enter {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.mg-card .mg-muted {
font-size: 12px;
}
.mg-pin {
width: var(--manage-pin-size);
height: var(--manage-pin-size);
padding: 0;
border: 0;
border-radius: var(--manage-pin-radius);
display: grid;
place-items: center;
color: var(--tx-3);
background: transparent;
cursor: pointer;
transition:
color var(--t-fast),
background var(--t-fast);
}
.mg-pin:hover {
background: rgb(var(--ink) / 0.08);
color: var(--tx-1);
}
.mg-pin[aria-pressed="true"] {
color: var(--gold);
}
.mg-pin[aria-pressed="true"] svg {
fill: currentColor;
}
@@ -22,6 +22,7 @@
* real queue and connection status. Individual tools keep their routes. */
interface Props {
workspace?: boolean;
pageHeader?: import("svelte").Snippet;
active: NavId;
title: string;
subtitle?: string;
@@ -30,7 +31,13 @@
children?: import("svelte").Snippet;
}
let { title, actions, children, workspace = false }: Props = $props();
let {
title,
actions,
children,
pageHeader,
workspace = false,
}: Props = $props();
let paletteOpen = $state(false);
@@ -258,52 +265,57 @@
>
</div>
</aside>
<div class="app-body">
<header class="topbar">
<div class="breadcrumb">
{currentNav?.label ?? title}<span>/</span><span class="page-name"
>{title}</span
<div
class="app-body"
style:background={pageHeader ? "var(--manage-page-background)" : undefined}
>
{#if pageHeader}{@render pageHeader()}{:else}
<header class="topbar">
<div class="breadcrumb">
{currentNav?.label ?? title}<span>/</span><span class="page-name"
>{title}</span
>
</div>
<div class="grow"></div>
{#if currentNav?.id === "connections"}
<a
class="a-btn secondary sm"
href="/connections"
aria-current={page.url.pathname === "/connections"
? "page"
: undefined}>ComfyUI 连接</a
>
<a
class="a-btn secondary sm"
href="/models"
aria-current={page.url.pathname === "/models" ? "page" : undefined}
>大语言模型</a
>
{/if}
{#if scopeProjectId}<ProjectScope
projectId={scopeProjectId}
onProjectsList={false}
/>{/if}
{#if actions}<div class="topbar-actions">{@render actions()}</div>{/if}
<button
class="search-trigger"
onclick={openPalette}
aria-label={t("shell.searchTitle")}
aria-keyshortcuts="Control+K Meta+K"
><Icon name="search" size={16} /><span>搜索</span><kbd>Ctrl K</kbd
></button
>
</div>
<div class="grow"></div>
{#if currentNav?.id === "connections"}
<a
class="a-btn secondary sm"
href="/connections"
aria-current={page.url.pathname === "/connections"
? "page"
: undefined}>ComfyUI 连接</a
<label class="motion"
><Switch
checked={reduceMotion.system || reduceMotion.manual}
disabled={reduceMotion.system}
label={t("shell.reduceMotion")}
onchange={setReduceMotion}
/><span>减少动效</span></label
>
<a
class="a-btn secondary sm"
href="/models"
aria-current={page.url.pathname === "/models" ? "page" : undefined}
>大语言模型</a
>
{/if}
{#if scopeProjectId}<ProjectScope
projectId={scopeProjectId}
onProjectsList={false}
/>{/if}
{#if actions}<div class="topbar-actions">{@render actions()}</div>{/if}
<button
class="search-trigger"
onclick={openPalette}
aria-label={t("shell.searchTitle")}
aria-keyshortcuts="Control+K Meta+K"
><Icon name="search" size={16} /><span>搜索</span><kbd>Ctrl K</kbd
></button
>
<label class="motion"
><Switch
checked={reduceMotion.system || reduceMotion.manual}
disabled={reduceMotion.system}
label={t("shell.reduceMotion")}
onchange={setReduceMotion}
/><span>减少动效</span></label
>
<LanguageSwitcher /><QueueTray jobs={genJobs} />
</header>
<LanguageSwitcher /><QueueTray jobs={genJobs} />
</header>
{/if}
<div class="body" class:bounded={workspace}>{@render children?.()}</div>
</div>
</div>
@@ -1,6 +1,9 @@
<script lang="ts">
import { THEMES, theme, setTheme, themeStorageUnavailable } from "$lib/theme";
let { compact = false }: { compact?: boolean } = $props();
let {
compact = false,
tiles = false,
}: { compact?: boolean; tiles?: boolean } = $props();
function move(event: KeyboardEvent) {
const steps: Record<string, number> = {
ArrowRight: 1,
@@ -28,26 +31,38 @@
}
</script>
<div class="choices" class:compact role="group" aria-label="外观主题">
<div
class="choices"
class:compact
class:tiles
role="group"
aria-label="外观主题"
>
{#each THEMES as item}
<button
class="choice"
class:selected={$theme === item.id}
aria-pressed={$theme === item.id}
onkeydown={move}
onclick={() => setTheme(item.id)}
onclick={(event) => setTheme(item.id, event)}
>
<span class="preview" data-theme={item.id} aria-hidden="true"
><span class="rail"></span><span class="list"
><i></i><i></i><i></i></span
><span class="stage"><i></i><b></b></span></span
>
<span class="copy"
><strong>{item.name}</strong><small>{item.english}</small><span
>{item.description}</span
><span class="stage"
><i></i><b
>{#if tiles}<em></em>{/if}</b
></span
></span
>
<span class="check" aria-hidden="true"
<span class="copy"
><strong
>{item.name}{#if tiles && item.id === "night"}
<span class="mg-tag theme-default">默认</span>{/if}</strong
><small>{tiles ? item.description.split(" · ")[0] : item.english}</small
>{#if !tiles}<span>{item.description}</span>{/if}</span
>
<span class="check" class:tile-check={tiles} aria-hidden="true"
>{$theme === item.id ? "✓" : ""}</span
>
</button>
@@ -63,16 +78,38 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.choices.tiles {
--choice-check-width: 0px;
--choice-box-sizing: border-box;
--choice-gap: 6px;
--choice-copy-gap: 6px;
--choice-tone-align: flex-start;
--choice-preview-art: var(--theme-preview-art);
--choice-dock-width: 100%;
--choice-dock-background: var(--bg-3);
--choice-radius: 12px;
--choice-name-size: 12.5px;
--choice-tone-font: 11px var(--font-body);
--choice-selected: color-mix(in srgb, var(--gold) 60%, transparent);
--choice-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 14%, transparent);
}
.tile-check {
display: none;
}
.tiles .copy {
flex-direction: column;
}
.choices.compact {
grid-template-columns: 1fr;
}
.choice {
box-sizing: var(--choice-box-sizing, content-box);
display: grid;
grid-template-columns: minmax(0, 1fr) 18px;
gap: 8px;
grid-template-columns: minmax(0, 1fr) var(--choice-check-width, 18px);
gap: var(--choice-gap, 8px);
padding: 8px;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
border-radius: var(--choice-radius, var(--r-md));
background: var(--bg-2);
color: var(--tx-1);
text-align: left;
@@ -83,7 +120,8 @@
background: var(--bg-3);
}
.choice.selected {
border-color: var(--gold);
border-color: var(--choice-selected, var(--gold));
box-shadow: var(--choice-shadow, none);
}
.choice:focus-visible {
outline: 2px solid var(--info);
@@ -94,6 +132,7 @@
align-items: center;
}
.preview {
box-sizing: var(--choice-box-sizing, content-box);
grid-column: 1 / -1;
height: 64px;
border-radius: var(--r-sm);
@@ -144,29 +183,37 @@
.stage i {
flex: 1;
border-radius: 3px;
background: var(--bg-3);
background: var(--choice-preview-art, var(--bg-3));
border: 1px solid var(--line-2);
}
.stage b {
height: 9px;
width: 18px;
width: var(--choice-dock-width, 18px);
align-self: end;
border-radius: 2px;
background: var(--choice-dock-background, var(--grad-gold));
display: flex;
justify-content: flex-end;
padding: 1px;
}
.stage em {
width: 14px;
border-radius: 2px;
background: var(--grad-gold);
}
.copy {
display: flex;
flex-wrap: wrap;
gap: 2px 6px;
gap: var(--choice-copy-gap, 2px) 6px;
min-width: 0;
}
.copy strong {
font-size: 13px;
font-size: var(--choice-name-size, 13px);
}
.copy small {
font: 10px var(--mono);
font: var(--choice-tone-font, 10px var(--mono));
color: var(--tx-3);
align-self: center;
align-self: var(--choice-tone-align, center);
}
.copy > span {
width: 100%;
@@ -182,8 +229,13 @@
margin: 12px 0 0;
}
@media (max-width: 1366px) {
.choices:not(.compact) {
.choices:not(.compact):not(.tiles) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.theme-default {
box-sizing: border-box;
height: 16px;
font-size: 10px;
}
</style>
+26 -26
View File
@@ -1,37 +1,37 @@
<script lang="ts">
import { icons, type IconName } from './icons';
import { icons, type IconName } from "./icons";
interface Props {
name: IconName;
size?: number;
}
interface Props {
name: IconName;
size?: number;
}
let { name, size = 18 }: Props = $props();
let { name, size = 18 }: Props = $props();
const content = $derived(icons[name] ?? '');
const content = $derived(icons[name] ?? "");
</script>
{#if content}
<svg
class="icon"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{@html content}
</svg>
<svg
class="icon"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
style:stroke-width="var(--icon-stroke, 1.5)"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{@html content}
</svg>
{/if}
<style>
.icon {
display: inline-block;
vertical-align: -0.125em;
flex-shrink: 0;
}
.icon {
display: inline-block;
vertical-align: -0.125em;
flex-shrink: 0;
}
</style>
+75 -70
View File
@@ -4,76 +4,81 @@
* Each entry is SVG inner content rendered by Icon.svelte via {@html}.
*/
export const icons = {
// Navigation
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
story:
'<path d="M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 4h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
assets:
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
script:
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
video:
'<rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20"/><path d="M17 2v20"/><path d="M2 12h20"/><path d="M2 7h5"/><path d="M2 17h5"/><path d="M17 17h5"/><path d="M17 7h5"/>',
queue:
'<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
settings:
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
playground:
'<path d="M10 2v7.53a2 2 0 0 1-.21.9L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45L14.21 10.4a2 2 0 0 1-.21-.9V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
// Actions
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'link-off':
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 2 8"/><path d="m8 8 8 8"/>',
trash:
'<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
retry:
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
download:
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
upload:
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
paperclip:
'<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
copy:
'<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>',
'external-link':
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>',
'zoom-in':
'<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/><path d="M11 8v6"/><path d="M8 11h6"/>',
// Media transport
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
pause:
'<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
// Status
check: '<path d="M20 6 9 17l-5-5"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
alert:
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
sparkle:
'<path d="M12 3l1.9 5.76a2 2 0 0 0 1.24 1.24L21 12l-5.86 1.9a2 2 0 0 0-1.24 1.24L12 21l-1.9-5.86a2 2 0 0 0-1.24-1.24L3 12l5.86-1.9a2 2 0 0 0 1.24-1.24Z"/>',
// Media
image:
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/>',
film: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 8h20"/><path d="M2 16h20"/><path d="M9 8v8"/><path d="M15 8v8"/>',
music:
'<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
folder:
'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
// Chevrons
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-up': '<path d="m18 15-6-6-6 6"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
// Misc
drag: '<circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/>',
"search-manage": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
"upload-manage": '<path d="M12 16V4M6 9l6-6 6 6M4 20h16"/>',
"film-manage":
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/>',
"arrow-right": '<path d="M5 12h14M13 6l6 6-6 6"/>',
pin: '<path d="M12 17v5M5 17h14l-2-4V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v8z"/>',
// Navigation
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
story:
'<path d="M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 4h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
assets:
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
script:
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
video:
'<rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20"/><path d="M17 2v20"/><path d="M2 12h20"/><path d="M2 7h5"/><path d="M2 17h5"/><path d="M17 17h5"/><path d="M17 7h5"/>',
queue:
'<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
settings:
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
playground:
'<path d="M10 2v7.53a2 2 0 0 1-.21.9L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45L14.21 10.4a2 2 0 0 1-.21-.9V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
// Actions
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
"link-off":
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 2 8"/><path d="m8 8 8 8"/>',
trash:
'<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
retry:
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
download:
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
upload:
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
paperclip:
'<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
copy: '<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>',
"external-link":
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>',
"zoom-in":
'<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/><path d="M11 8v6"/><path d="M8 11h6"/>',
// Media transport
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
pause:
'<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
// Status
check: '<path d="M20 6 9 17l-5-5"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
alert:
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
sparkle:
'<path d="M12 3l1.9 5.76a2 2 0 0 0 1.24 1.24L21 12l-5.86 1.9a2 2 0 0 0-1.24 1.24L12 21l-1.9-5.86a2 2 0 0 0-1.24-1.24L3 12l5.86-1.9a2 2 0 0 0 1.24-1.24Z"/>',
// Media
image:
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/>',
film: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 8h20"/><path d="M2 16h20"/><path d="M9 8v8"/><path d="M15 8v8"/>',
music:
'<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
folder:
'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
// Chevrons
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"chevron-up": '<path d="m18 15-6-6-6 6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
// Misc
drag: '<circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/>',
} as const satisfies Record<string, string>;
export type IconName = keyof typeof icons;
File diff suppressed because it is too large Load Diff
+47 -9
View File
@@ -1,3 +1,4 @@
import { tick } from "svelte";
import { writable } from "svelte/store";
/** Approved HEL-726 themes. Colours live only in static/themes.css. */
@@ -36,16 +37,53 @@ export function isTheme(value: unknown): value is ThemeId {
return THEMES.some((item) => item.id === value);
}
export function setTheme(id: ThemeId) {
let themeRequest = 0;
let transition: ViewTransition | undefined;
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
/** HEL-726: 520ms reveal, 280ms fallback, immediate under reduced motion.
* Only root tokens and the store change; no editor/component is remounted. */
export function setTheme(id: ThemeId, event?: MouseEvent) {
if (typeof document === "undefined" || !isTheme(id)) return;
// Only the root attribute changes: editors, uploads and jobs stay mounted.
document.documentElement.dataset.theme = id;
theme.set(id);
try {
localStorage.setItem(THEME_KEY, id);
themeStorageUnavailable.set(false);
} catch {
themeStorageUnavailable.set(true);
const root = document.documentElement;
const request = ++themeRequest;
transition?.skipTransition();
clearTimeout(fallbackTimer);
root.classList.remove("theme-anim");
if (root.dataset.theme === id) return;
const apply = () => {
if (request !== themeRequest) return;
root.dataset.theme = id;
theme.set(id);
try {
localStorage.setItem(THEME_KEY, id);
themeStorageUnavailable.set(false);
} catch {
themeStorageUnavailable.set(true);
}
};
const reduced =
root.dataset.reduce === "1" ||
matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
apply();
return;
}
if (document.startViewTransition) {
if (event && event.detail !== 0) {
root.style.setProperty("--theme-x", event.clientX + "px");
root.style.setProperty("--theme-y", event.clientY + "px");
}
transition = document.startViewTransition(async () => {
apply();
await tick();
});
void transition.ready.catch(() => {});
void transition.finished.catch(() => {});
} else {
root.classList.add("theme-anim");
apply();
fallbackTimer = setTimeout(() => root.classList.remove("theme-anim"), 340);
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { WorkflowMeta } from "./comfy/types";
/** Shared contract for creation and management. A directory file is never a runnable workflow. */
export function isWorkflowRunnable(
row: Pick<WorkflowMeta, "is_enabled" | "usage" | "needs_mapping">,
): boolean {
return row.is_enabled && !!row.usage && !row.needs_mapping;
}
export function workflowState(row: WorkflowMeta): {
label: string;
tone: string;
} {
if (row.needs_mapping || !row.usage) return { label: "待配置", tone: "warn" };
if (!row.is_enabled) return { label: "已停用", tone: "" };
return { label: "可运行", tone: "ok" };
}
+15 -20
View File
@@ -68,13 +68,13 @@ code,
align-items: center;
justify-content: center;
gap: var(--sp-2);
min-height: 34px;
min-height: var(--button-height, 34px);
padding: 0 var(--sp-6);
border-radius: var(--r-md);
border-radius: var(--button-radius, var(--r-md));
border: 1px solid transparent;
font-family: inherit;
font-size: var(--fs-body);
font-weight: 600;
font-size: var(--button-font-size, var(--fs-body));
font-weight: var(--button-weight, 600);
white-space: nowrap;
text-decoration: none;
cursor: pointer;
@@ -92,10 +92,10 @@ code,
padding: 0 var(--sp-7);
}
.a-btn.sm {
min-height: 28px;
padding: 0 var(--sp-4);
min-height: var(--button-sm-height, 28px);
padding: 0 var(--button-sm-padding, var(--sp-4));
font-size: var(--fs-aux);
border-radius: var(--r-sm);
border-radius: var(--button-sm-radius, var(--r-sm));
}
.a-btn.xs {
min-height: 24px;
@@ -107,12 +107,19 @@ code,
width: 100%;
}
.a-btn.primary {
border-color: transparent;
border-bottom-width: 0;
min-height: var(--button-primary-height, 40px);
padding: 0 var(--button-primary-padding, 18px);
border-radius: var(--button-primary-radius, 8px);
font-weight: var(--button-primary-weight, 550);
background: var(--accent-grad);
color: var(--accent-ink);
box-shadow: var(--btn-glow);
}
.a-btn.secondary {
background: var(--bg-3);
background: var(--button-secondary-bg, var(--surface));
border-radius: var(--button-secondary-radius, 8px);
border-color: var(--line);
color: var(--text);
}
@@ -949,18 +956,6 @@ select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.a-btn.primary {
border-color: transparent;
border-bottom-width: 0;
min-height: 40px;
padding: 0 18px;
border-radius: 8px;
font-weight: 550;
}
.a-btn.secondary {
background: var(--surface);
border-radius: 8px;
}
.a-stage {
background: var(--stage-bg);
border-radius: 14px;
File diff suppressed because it is too large Load Diff
+242 -485
View File
@@ -1,518 +1,275 @@
<script lang="ts">
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { libraryApi, type LibraryMediaItem } from '$lib/api';
import { assetUrl } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
import AttachToProject from '$lib/components/AttachToProject.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import { toast } from '$lib/toast';
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { libraryApi, type LibraryMediaItem } from "$lib/api";
import { assetUrl } from "$lib/api";
import { t } from "$lib/i18n.svelte";
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
import AttachToProject from "$lib/components/AttachToProject.svelte";
import ImageLightbox from "$lib/components/ImageLightbox.svelte";
import SafeMedia from "$lib/components/SafeMedia.svelte";
import Button from "$lib/components/ui/Button.svelte";
import ConfirmDialog from "$lib/components/ui/ConfirmDialog.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import { toast } from "$lib/toast";
const client = useQueryClient();
const client = useQueryClient();
const mediaQuery = createQuery({
queryKey: ['library-media'],
queryFn: libraryApi.list,
});
const mediaQuery = createQuery({
queryKey: ["library-media"],
queryFn: libraryApi.list,
});
const items = $derived($mediaQuery.data ?? []);
const items = $derived($mediaQuery.data ?? []);
// ---- filter + lazy paging ------------------------------------------------
// The listing endpoint returns the full (small) file index; filtering and
// paging are client-side. Media heavy <video> cards make a 50-per-page cap
// necessary even so.
// ---- filter + lazy paging ------------------------------------------------
// The listing endpoint returns the full (small) file index; filtering and
// paging are client-side. Media heavy <video> cards make a 50-per-page cap
// necessary even so.
const PAGE_SIZE = 50;
let filter = $state<'all' | 'image' | 'video'>('all');
let visibleCount = $state(PAGE_SIZE);
let loadingMore = $state(false);
let sentinel = $state<HTMLElement | null>(null);
const PAGE_SIZE = 50;
let filter = $state<"all" | "image" | "video">("all");
let visibleCount = $state(PAGE_SIZE);
let loadingMore = $state(false);
let sentinel = $state<HTMLElement | null>(null);
const filtered = $derived(filter === 'all' ? items : items.filter((i) => i.kind === filter));
const visible = $derived(filtered.slice(0, visibleCount));
const hasMore = $derived(filtered.length > visible.length);
const remaining = $derived(filtered.length - visible.length);
const filtered = $derived(
filter === "all" ? items : items.filter((i) => i.kind === filter),
);
const visible = $derived(filtered.slice(0, visibleCount));
const hasMore = $derived(filtered.length > visible.length);
const remaining = $derived(filtered.length - visible.length);
function loadMore() {
if (!hasMore || loadingMore) return;
loadingMore = true;
// Yield a frame so the loading row paints before the (sync) append.
setTimeout(() => {
visibleCount += PAGE_SIZE;
loadingMore = false;
}, 120);
}
function loadMore() {
if (!hasMore || loadingMore) return;
loadingMore = true;
// Yield a frame so the loading row paints before the (sync) append.
setTimeout(() => {
visibleCount += PAGE_SIZE;
loadingMore = false;
}, 120);
}
$effect(() => {
// Auto-append the next page when the sentinel scrolls near the viewport.
if (!sentinel || !hasMore) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) loadMore();
},
{ rootMargin: '400px' },
);
io.observe(sentinel);
return () => io.disconnect();
});
$effect(() => {
// Auto-append the next page when the sentinel scrolls near the viewport.
if (!sentinel || !hasMore) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) loadMore();
},
{ rootMargin: "400px" },
);
io.observe(sentinel);
return () => io.disconnect();
});
$effect(() => {
// New filter → back to the first page.
void filter;
visibleCount = PAGE_SIZE;
});
$effect(() => {
// New filter → back to the first page.
void filter;
visibleCount = PAGE_SIZE;
});
let selected = $state<Set<string>>(new Set());
let preview = $state<{
src: string;
kind: 'image' | 'video';
name: string;
} | null>(null);
let attachItem = $state<LibraryMediaItem | null>(null);
let confirmingDelete = $state(false);
let selected = $state<Set<string>>(new Set());
let preview = $state<{
src: string;
kind: "image" | "video";
name: string;
} | null>(null);
let attachItem = $state<LibraryMediaItem | null>(null);
let confirmingDelete = $state(false);
const selectedCount = $derived(selected.size);
const allSelected = $derived(filtered.length > 0 && filtered.every((i) => selected.has(i.path)));
const selectedCount = $derived(selected.size);
const allSelected = $derived(
filtered.length > 0 && filtered.every((i) => selected.has(i.path)),
);
function toggle(path: string) {
const next = new Set(selected);
if (next.has(path)) next.delete(path);
else next.add(path);
selected = next;
}
function toggle(path: string) {
const next = new Set(selected);
if (next.has(path)) next.delete(path);
else next.add(path);
selected = next;
}
function toggleAll() {
selected = allSelected ? new Set() : new Set(filtered.map((i) => i.path));
}
function toggleAll() {
selected = allSelected ? new Set() : new Set(filtered.map((i) => i.path));
}
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const deleteMutation = createMutation({
mutationFn: (paths: string[]) => libraryApi.deleteMany(paths),
onSuccess: (res) => {
selected = new Set();
confirmingDelete = false;
void client.invalidateQueries({ queryKey: ['library-media'] });
const kept = res.results.filter((r) => r.status === 'kept');
if (kept.length > 0) {
toast.error(t('library.deletedPartial', { kept: kept.length }));
} else {
toast.success(t('library.deleted', { count: res.deleted.length }));
}
},
onError: (err) => {
confirmingDelete = false;
toast.error(err instanceof Error ? err.message : t('library.deleteFailed'));
},
});
const deleteMutation = createMutation({
mutationFn: (paths: string[]) => libraryApi.deleteMany(paths),
onSuccess: (res) => {
selected = new Set();
confirmingDelete = false;
void client.invalidateQueries({ queryKey: ["library-media"] });
const kept = res.results.filter((r) => r.status === "kept");
if (kept.length > 0) {
toast.error(t("library.deletedPartial", { kept: kept.length }));
} else {
toast.success(t("library.deleted", { count: res.deleted.length }));
}
},
onError: (err) => {
confirmingDelete = false;
toast.error(
err instanceof Error ? err.message : t("library.deleteFailed"),
);
},
});
function deleteSelected() {
if (selected.size === 0) return;
$deleteMutation.mutate([...selected]);
}
function deleteSelected() {
if (selected.size === 0) return;
$deleteMutation.mutate([...selected]);
}
function openPreview(item: LibraryMediaItem) {
const src = assetUrl(item.path);
if (!src) return;
preview = { src, kind: item.kind, name: item.name };
}
function openPreview(item: LibraryMediaItem) {
const src = assetUrl(item.path);
if (!src) return;
preview = { src, kind: item.kind, name: item.name };
}
</script>
<AppShell active="library" title={t('nav.library')} subtitle={t('library.subtitle')}>
{#snippet actions()}
<span class="status-chip" data-testid="library-count">
{t('library.itemCount', { count: filtered.length })}
</span>
{#if selectedCount > 0}
<Button variant="danger" onclick={() => (confirmingDelete = true)}>
<Icon name="close" size={14} />
{t('library.deleteSelected', { count: selectedCount })}
</Button>
{/if}
{/snippet}
<div class="library">
<header class="refract-library-title">
<div class="eyebrow">YOUR CREATIVE MATERIALS</div>
<h1>创作的原材料。</h1>
<p>让每一次探索,留下可继续创作的线索。</p>
</header>
<nav class="resource-tabs">
<a class="active" href="/library">参考素材</a><a href="/workflows">模型与工作流</a>
</nav>
<div class="toolbar">
<div class="filters" role="tablist" aria-label={t('library.filterLabel')}>
<button
type="button"
class="a-chip"
class:sel={filter === 'all'}
aria-pressed={filter === 'all'}
onclick={() => (filter = 'all')}
>
<span class="lbl">{t('library.filterAll')}</span>
<span class="chip-count">{items.length}</span>
</button>
<button
type="button"
class="a-chip"
class:sel={filter === 'image'}
aria-pressed={filter === 'image'}
onclick={() => (filter = 'image')}
>
<span class="lbl">{t('library.filterImage')}</span>
<span class="chip-count">{items.filter((i) => i.kind === 'image').length}</span>
</button>
<button
type="button"
class="a-chip"
class:sel={filter === 'video'}
aria-pressed={filter === 'video'}
onclick={() => (filter = 'video')}
>
<span class="lbl">{t('library.filterVideo')}</span>
<span class="chip-count">{items.filter((i) => i.kind === 'video').length}</span>
</button>
</div>
<label class="select-all">
<input type="checkbox" checked={allSelected} onchange={toggleAll} />
<span>{allSelected ? t('library.deselectAll') : t('library.selectAll')}</span>
</label>
</div>
{#if $mediaQuery.isLoading}
<div class="grid" aria-busy="true">
{#each Array(8) as _, i (i)}
<div class="card skeleton-card"><Skeleton height="220px" /></div>
{/each}
</div>
{:else if $mediaQuery.isError}
<EmptyState title={t('library.loadError')}>
{#snippet action()}
<Button variant="secondary" onclick={() => $mediaQuery.refetch()}>
<Icon name="retry" size={14} />
{t('common.retry')}
</Button>
{/snippet}
</EmptyState>
{:else if filtered.length === 0}
<EmptyState
title={filter === 'all'
? t('library.emptyTitle')
: t('library.emptyFilterTitle', {
kind: filter === 'image' ? t('library.filterImage') : t('library.filterVideo'),
})}
body={filter === 'all' ? t('library.emptyBody') : t('library.emptyFilterBody')}
/>
{:else}
<div class="grid">
{#each visible as item (item.path)}
<div
class="card"
class:selected={selected.has(item.path)}
class:a-frame={selected.has(item.path)}
style:--frame-inset="3px"
style:--frame-arm="16px"
>
<label class="check">
<input
type="checkbox"
checked={selected.has(item.path)}
onchange={() => toggle(item.path)}
/>
</label>
<button
type="button"
class="media a-photo"
onclick={() => openPreview(item)}
title={item.name}
>
<SafeMedia
src={assetUrl(item.path)}
kind={item.kind}
alt={item.name}
controls={false}
/>
</button>
<div class="meta">
<span class="name" title={item.name}>{item.name}</span>
<span class="sub">{item.kind} · {fmtSize(item.size)}</span>
</div>
<div class="card-actions">
<Button variant="secondary" onclick={() => (attachItem = item)}>
{t('library.addToProject')}
</Button>
</div>
</div>
{/each}
</div>
{#if hasMore}
<div class="load-more" bind:this={sentinel} aria-hidden="true"></div>
<div class="more-row">
{#if loadingMore}
<span class="loading-note">{t('library.loadingMore')}</span>
{:else}
<Button variant="secondary" onclick={loadMore}>
{t('library.loadMore', { count: remaining })}
</Button>
{/if}
</div>
{/if}
{/if}
</div>
</AppShell>
<ManagementPage active="library" title="素材库" scene="gold">
{#snippet tools()}<div class="mg-seg asset-tabs" aria-label="素材类型">
{#each [["all", "全部"], ["image", "图片"], ["video", "视频"]] as [value, label]}<button
class:on={filter === value}
aria-pressed={filter === value}
onclick={() => (filter = value as typeof filter)}>{label}</button
>{/each}<button disabled title="当前素材接口不提供音频索引">音频</button
><button disabled title="当前素材接口不提供项目归属信息">未归属</button>
</div>{/snippet}
{#snippet actions()}
<span class="mg-dim">{filtered.length} 项素材</span><label class="mg-dim"
><input type="checkbox" checked={allSelected} onchange={toggleAll} />
{allSelected ? t("library.deselectAll") : t("library.selectAll")}</label
>
<span class="mg-tag" title="当前素材索引没有项目归属信息">项目:全部</span
>{#if selectedCount}<Button
size="sm"
variant="danger"
onclick={() => (confirmingDelete = true)}
>删除已选 {selectedCount} 项</Button
>{/if}{/snippet}
{#if $mediaQuery.isLoading}<p class="mg-empty">
正在读取素材…
</p>{:else if $mediaQuery.isError}<div class="mg-empty" role="alert">
素材加载失败<Button onclick={() => $mediaQuery.refetch()}>重试</Button>
</div>{:else if !visible.length}<p class="mg-empty">
还没有素材。创作台生成的作品会在这里保留。
</p>{:else}
<div class="mg-media">
{#each visible as item (item.path)}<article class="asset-card">
<button
class="asset-preview"
onclick={() => openPreview(item)}
aria-label={"预览 " + item.name}
><SafeMedia
src={assetUrl(item.path)}
kind={item.kind}
alt={item.name}
controls={false}
/></button
>
<div class="asset-meta">
<label class="mg-name"
><input
type="checkbox"
checked={selected.has(item.path)}
onchange={() => toggle(item.path)}
/><b title={item.name}>{item.name}</b><small
>{item.kind} · {fmtSize(item.size)}</small
></label
><Button
variant="ghost"
size="sm"
onclick={() => (attachItem = item)}>存入项目</Button
>
</div>
</article>{/each}
{#if hasMore}<div bind:this={sentinel}>
<Button loading={loadingMore} onclick={loadMore}
>再显示 {Math.min(50, remaining)} 项</Button
>
</div>{/if}
</div>{/if}
</ManagementPage>
{#if preview}
<ImageLightbox
src={preview.src}
alt={preview.name}
kind={preview.kind}
caption={preview.name}
onClose={() => (preview = null)}
/>
<ImageLightbox
src={preview.src}
alt={preview.name}
kind={preview.kind}
caption={preview.name}
onClose={() => (preview = null)}
/>
{/if}
<Modal
open={attachItem !== null}
title={t('library.addToProject')}
onclose={() => (attachItem = null)}
open={attachItem !== null}
title={t("library.addToProject")}
onclose={() => (attachItem = null)}
>
{#if attachItem}
<p class="modal-file">{attachItem.name}</p>
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
{/if}
{#if attachItem}
<p class="modal-file">{attachItem.name}</p>
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
{/if}
</Modal>
<ConfirmDialog
open={confirmingDelete}
title={t('library.deleteSelected', { count: selectedCount })}
message={t('library.confirmDelete', { count: selectedCount })}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger={true}
onconfirm={deleteSelected}
oncancel={() => (confirmingDelete = false)}
open={confirmingDelete}
title={t("library.deleteSelected", { count: selectedCount })}
message={t("library.confirmDelete", { count: selectedCount })}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
danger={true}
onconfirm={deleteSelected}
oncancel={() => (confirmingDelete = false)}
/>
<style>
.library {
padding: 20px 28px 40px;
max-width: 1560px;
width: 100%;
box-sizing: border-box;
margin: 0 auto;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--sp-6);
margin-bottom: var(--sp-5);
flex-wrap: wrap;
}
.filters {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.chip-count {
font-size: var(--fs-mini);
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.select-all {
display: flex;
align-items: center;
gap: var(--sp-3);
font-size: var(--fs-body-sm);
color: var(--text-2);
cursor: pointer;
}
.select-all input {
accent-color: var(--accent-lo);
width: 15px;
height: 15px;
cursor: pointer;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--sp-4);
}
.card {
position: relative;
display: flex;
flex-direction: column;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
transition: border-color var(--d-state) ease;
}
.card.selected {
background: linear-gradient(180deg, var(--accent-soft), transparent 32%), var(--bg-2);
}
.card :global(.media-el) {
object-fit: cover;
}
.skeleton-card {
height: 240px;
}
.check {
position: absolute;
top: var(--sp-3);
left: var(--sp-3);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: var(--media-scrim);
border-radius: var(--r-sm);
cursor: pointer;
}
.check input {
width: 15px;
height: 15px;
accent-color: var(--accent-lo);
cursor: pointer;
}
.media {
display: block;
width: 100%;
height: 150px;
padding: 0;
border: none;
background: var(--media-scrim);
cursor: zoom-in;
overflow: hidden;
}
/* 相纸白边 (HEL-680):素材墙里的作品按相纸呈现 */
.media.a-photo {
border: var(--paper-w, 4px) solid var(--accent);
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
background: var(--accent);
border-radius: 0;
box-sizing: border-box;
}
.media :global(img),
.media :global(video) {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--sp-4) var(--sp-5) var(--sp-2);
min-width: 0;
}
.name {
font-size: var(--fs-body);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: var(--fs-mini);
color: var(--text-3);
}
.card-actions {
padding: var(--sp-3) var(--sp-5) var(--sp-5);
}
.load-more {
height: 1px;
}
.more-row {
display: flex;
justify-content: center;
padding: var(--sp-6) 0 var(--sp-2);
}
.loading-note {
font-size: var(--fs-body-sm);
color: var(--text-3);
}
.modal-file {
margin: 0 0 var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 760px) {
.library {
padding: var(--sp-5) var(--sp-5) var(--sp-8);
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--sp-3);
}
}
.library {
padding: 28px 36px 56px;
}
.refract-library-title {
margin-bottom: 25px;
}
.eyebrow {
font-size: 10px;
color: var(--text-3);
letter-spacing: 1.3px;
}
.refract-library-title h1 {
margin: 8px 0 7px;
font-size: 34px;
letter-spacing: -1.3px;
font-weight: 600;
}
.refract-library-title p {
font-size: 11px;
color: var(--text-3);
margin: 0;
}
.resource-tabs {
display: flex;
gap: 20px;
margin-bottom: 24px;
border-bottom: 1px solid var(--line);
}
.resource-tabs a {
padding: 9px 0 12px;
font-size: 12px;
color: var(--text-3);
border-bottom: 2px solid transparent;
text-decoration: none;
}
.resource-tabs a.active {
border-color: var(--accent);
color: var(--accent);
}
.library {
max-width: none;
padding: 24px 28px 30px;
}
h1 {
font-size: 26px;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.asset-tabs {
width: 300px;
margin-left: 12px;
}
.asset-card {
background: var(--bg-1);
}
.asset-preview {
width: 100%;
padding: 0;
border: 0;
background: var(--media-bg);
cursor: pointer;
display: block;
}
.asset-preview :global(img),
.asset-preview :global(video) {
display: block;
width: 100%;
height: auto;
}
.asset-meta {
padding: 8px;
display: flex;
gap: 8px;
align-items: center;
}
.asset-meta label {
flex: 1;
}
.asset-meta input {
position: absolute;
top: 8px;
left: 8px;
}
.modal-file {
overflow-wrap: anywhere;
}
</style>
File diff suppressed because it is too large Load Diff
+331 -498
View File
@@ -5,7 +5,9 @@
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import AppShell from "$lib/components/shell/AppShell.svelte";
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
import LanguageSwitcher from "$lib/components/LanguageSwitcher.svelte";
import { reduceMotion, setReduceMotion } from "$lib/reduceMotion.svelte";
import ThemeChoices from "$lib/components/shell/ThemeChoices.svelte";
import MemoryPanel from "$lib/components/settings/MemoryPanel.svelte";
import Button from "$lib/components/ui/Button.svelte";
@@ -58,37 +60,11 @@
},
];
const ELSEWHERE: {
href: string;
labelKey: string;
blurbKey: string;
icon: IconName;
}[] = [
{
href: "/models",
labelKey: "shell.navModels",
blurbKey: "settings.linkModelsBlurb",
icon: "assets",
},
{
href: "/connections",
labelKey: "shell.navConnections",
blurbKey: "settings.linkConnectionsBlurb",
icon: "link",
},
{
href: "/agents",
labelKey: "shell.navAgents",
blurbKey: "settings.linkAgentsBlurb",
icon: "sparkle",
},
];
// Old Calliope tab links (/settings?tab=llm …) land on the page that now
// owns that setting instead of a dead tab.
const MOVED_TABS: Record<string, string> = {
llm: "/models",
workflows: "/connections",
workflows: "/workflows",
skills: "/agents",
};
@@ -125,24 +101,6 @@
);
const isDirty = $derived(dirtyKeys.length > 0);
const FIELD_SECTION: Record<string, Section> = {
dry_run: "queue",
queue_concurrency: "queue",
queue_poll_interval_sec: "queue",
queue_poll_timeout_sec: "queue",
queue_max_retries: "queue",
agent_max_steps: "agent",
agent_hardening_prompt: "agent",
agent_history_char_budget: "agent",
agent_shell_enabled: "agent",
data_dir: "storage",
assets_dir: "storage",
agent_workspace_dir: "storage",
};
const dirtySections = $derived(
new Set(dirtyKeys.map((k) => FIELD_SECTION[k]).filter(Boolean)),
);
// Mirrors backend Field(ge=..., le=...) limits — validated client-side so
// save never trips a raw 422.
const NUMERIC_LIMITS: Record<
@@ -339,14 +297,6 @@
return s.agent_hardening_prompt ?? "";
}
function selectSection(id: Section) {
goto(`/settings?tab=${id}`, {
replaceState: true,
noScroll: true,
keepFocus: true,
});
}
type NumField = {
key: keyof Settings & string;
labelKey: string;
@@ -379,7 +329,6 @@
</script>
<svelte:window onbeforeunload={onBeforeUnload} />
{#snippet numberField(s: Settings, f: NumField)}
{@const limits = NUMERIC_LIMITS[f.key]}
<label class="field">
@@ -409,266 +358,263 @@
</label>
{/snippet}
<AppShell
active="settings"
title={t("shell.navSettings")}
subtitle={t("settings.pageSub")}
>
{#snippet actions()}
<span class="status-chip" class:accent={isDirty}>
{#if isDirty}<span class="status-chip-dot" aria-hidden="true"></span>{/if}
{isDirty ? t("settings.unsaved") : t("settings.allSaved")}
</span>
<Button
variant="ghost"
disabled={!isDirty || $saveMutation.isPending}
onclick={discardDraft}
>
{t("settings.discard")}
</Button>
<Button
variant="primary"
disabled={!isDirty || !isValid}
loading={$saveMutation.isPending}
onclick={() => $saveMutation.mutate()}
>
{t("settings.saveChanges")}
</Button>
{/snippet}
<div class="page">
<nav class="sections" aria-label={t("shell.navSettings")}>
{#each SECTIONS as sec (sec.id)}
<button
type="button"
class="sec"
class:active={tab === sec.id}
aria-current={tab === sec.id ? "page" : undefined}
onclick={() => selectSection(sec.id)}
>
<span class="sec-icon"><Icon name={sec.icon} size={15} /></span>
<span class="sec-copy">
<span class="sec-label">
{sec.id === "appearance" ? "外观主题" : t(sec.labelKey)}
{#if dirtySections.has(sec.id)}
<span class="dirty-dot" title={t("settings.unsaved")}></span>
{/if}
</span>
<span class="sec-blurb"
>{sec.id === "appearance"
? "选择片场灯光"
: t(sec.blurbKey)}</span
>
</span>
</button>
{/each}
<p class="elsewhere-head">{t("settings.elsewhere")}</p>
{#each ELSEWHERE as link (link.href)}
<a class="sec link" href={link.href}>
<span class="sec-icon"><Icon name={link.icon} size={15} /></span>
<span class="sec-copy">
<span class="sec-label">{t(link.labelKey)}</span>
<span class="sec-blurb">{t(link.blurbKey)}</span>
</span>
<span class="sec-arrow" aria-hidden="true">→</span>
</a>
{/each}
</nav>
<div class="content">
{#if tab === "appearance"}
<section class="a-panel">
<header class="panel-head">
<h2 class="a-panel-title">外观主题</h2>
<p class="a-panel-lead">同一个片场,换一种灯光。</p>
</header>
<ThemeChoices />
</section>
{:else if $settingsQuery.isLoading}
<p class="muted">{t("settings.loading")}</p>
{:else if $settingsQuery.isError}
<div class="a-panel error-panel" role="alert">
<Icon name="alert" size={16} />
<p>{t("settings.loadFailed")}</p>
<Button size="sm" onclick={() => $settingsQuery.refetch()}
>{t("common.retry")}</Button
<ManagementPage active="settings" title="设置">
<div class="settings-scroll">
<div class="settings-grid">
<section class="mg-field wide">
<h3>
外观主题 <span class="mg-dim"
>· 同一布局与功能;切换不影响正在编辑的内容,选择会被记住</span
>
</div>
{:else if $settingsQuery.data}
{@const s = $settingsQuery.data}
{#if tab === "queue"}
<section class="a-panel">
<header class="panel-head">
<h2 class="a-panel-title">{t("settings.queueSection")}</h2>
<p class="a-panel-lead">{t("settings.queueLead")}</p>
</header>
<div class="grid-2">
{#each QUEUE_FIELDS as f (f.key)}
{@render numberField(s, f)}
{/each}
</div>
</section>
<section class="a-panel">
<div class="switch-row">
<div class="switch-copy">
<h2 class="a-panel-title">{t("settings.dryRunTitle")}</h2>
<p class="a-panel-lead">{t("settings.dryRunHint")}</p>
</div>
<Switch
checked={boolValue(s, "dry_run")}
label={t("settings.dryRunLabel")}
onchange={(next) => (draft.dry_run = next)}
/>
</div>
</section>
{:else if tab === "agent"}
<section class="a-panel">
<header class="panel-head">
<h2 class="a-panel-title">{t("settings.agentRunSection")}</h2>
<p class="a-panel-lead">{t("settings.agentRunLead")}</p>
</header>
<div class="grid-2">
{@render numberField(s, {
key: "agent_max_steps",
labelKey: "settings.agentMaxStepsField",
hintKey: "settings.agentMaxStepsHint",
step: "1",
})}
<div class="budget">
{@render numberField(s, {
key: "agent_history_char_budget",
labelKey: "settings.historyBudgetField",
step: "1000",
})}
<div class="presets">
{#each HISTORY_BUDGET_PRESETS as preset (preset.value)}
<button
type="button"
class="preset"
class:active={historyBudgetActive(s, preset.value)}
onclick={() =>
(draft.agent_history_char_budget = String(
preset.value,
))}
>
{t(preset.labelKey)}
</button>
{/each}
</h3>
<ThemeChoices tiles />
</section>
<section class="mg-field wide">
<h3>界面语言 <span class="mg-tag">规则说明</span></h3>
<LanguageSwitcher segmented />
</section>
{#if $settingsQuery.isLoading}<p class="mg-empty">
正在读取设置…
</p>{:else if $settingsQuery.isError}<p role="alert">
设置加载失败<Button onclick={() => $settingsQuery.refetch()}
>重试</Button
>
</p>{:else if $settingsQuery.data}{@const s = $settingsQuery.data}
<section class="mg-field">
<h3>任务队列 <span class="mg-tag">规则说明</span></h3>
<div class="queue-fields">
{#each QUEUE_FIELDS.filter((f) => f.key === "queue_concurrency" || f.key === "queue_poll_timeout_sec") as f}<div
class="queue-row"
>
<label for={"setting-" + f.key}>{t(f.labelKey)}</label>
<div class="mg-step">
<button
aria-label={"减少" + t(f.labelKey)}
disabled={Number(fieldValue(f.key, s[f.key] as number)) <=
NUMERIC_LIMITS[f.key].min}
onclick={() =>
(draft[f.key] = String(
Math.max(
NUMERIC_LIMITS[f.key].min,
Number(fieldValue(f.key, s[f.key] as number)) - 1,
),
))}>−</button
><input
id={"setting-" + f.key}
type="number"
min={NUMERIC_LIMITS[f.key].min}
max={NUMERIC_LIMITS[f.key].max}
value={String(fieldValue(f.key, s[f.key] as number))}
oninput={(e) => (draft[f.key] = e.currentTarget.value)}
onblur={(e) => clampOnBlur(e, f.key)}
/><button
aria-label={"增加" + t(f.labelKey)}
disabled={Number(fieldValue(f.key, s[f.key] as number)) >=
NUMERIC_LIMITS[f.key].max}
onclick={() =>
(draft[f.key] = String(
Math.min(
NUMERIC_LIMITS[f.key].max,
Number(fieldValue(f.key, s[f.key] as number)) + 1,
),
))}>+</button
>
</div>
</div>
</div>
<p class="field-hint">{t("settings.historyBudgetHint")}</p>
</section>
<section class="a-panel">
<header class="panel-head">
<h2 class="a-panel-title">{t("settings.hardeningSection")}</h2>
<p class="a-panel-lead">
{t("settings.hardeningLead")}
{t("settings.hardeningCalloutStrong")}{t(
"settings.hardeningCalloutBody",
)}
</p>
</header>
<label class="field">
<span class="field-label"
>{t("settings.hardeningRulesLabel")}</span
{#if validationErrors[f.key]}<p class="field-error">
{validationErrors[f.key]}
</p>{/if}{/each}
</div>
</section>
<section class="mg-field">
<h3>动效强度</h3>
<div class="mg-options">
<button
class="mg-option"
class:on={!reduceMotion.system && !reduceMotion.manual}
aria-pressed={!reduceMotion.system && !reduceMotion.manual}
disabled={reduceMotion.system}
onclick={() => setReduceMotion(false)}>完整</button
><button
class="mg-option"
disabled
title="当前动效偏好只提供完整和减少动效,克制档尚未实现"
>克制</button
><button
class="mg-option"
class:on={reduceMotion.system || reduceMotion.manual}
aria-pressed={reduceMotion.system || reduceMotion.manual}
onclick={() => setReduceMotion(true)}>关闭(减少动效)</button
>
</div>
<p class="mg-dim">
系统开启“减少动态效果”时自动关闭;主题切换保留当前编辑内容。
</p>
</section>
<section class="mg-field">
<h3>Dry-run <span class="mg-tag warn">测试用</span></h3>
<div class="mg-row">
<span class="mg-dim">只写占位文件,不调用 ComfyUI</span><span
class="mg-grow"
></span><Switch
checked={boolValue(s, "dry_run")}
label="Dry-run"
onchange={(next) => (draft.dry_run = next)}
/>
</div>
</section>
<section class="mg-field">
<h3>存储路径 <span class="mg-tag">规则说明</span></h3>
<input
class="a-input"
aria-label={t("settings.dataDir")}
value={String(fieldValue("data_dir", s.data_dir))}
oninput={(e) => (draft.data_dir = e.currentTarget.value)}
/>
</section>
<details
class="advanced wide"
open={tab === "agent" || tab === "storage"}
>
<summary>高级设置 · 队列 / 存储 / 智能体</summary>
<div class="advanced-grid">
<section class="mg-field">
<h3>队列高级选项</h3>
{#each QUEUE_FIELDS.filter((f) => f.key !== "queue_concurrency" && f.key !== "queue_poll_timeout_sec") as f}{@render numberField(
s,
f,
)}{/each}
</section>
<section class="mg-field">
<h3>素材与工作目录</h3>
<label class="field"
><span class="field-label">{t("settings.assetsDir")}</span
><input
class="a-input"
value={String(fieldValue("assets_dir", s.assets_dir))}
oninput={(e) => (draft.assets_dir = e.currentTarget.value)}
/></label
><label class="field"
><span class="field-label">{t("settings.workspaceDir")}</span
><input
class="a-input"
value={String(
fieldValue("agent_workspace_dir", s.agent_workspace_dir),
)}
oninput={(e) =>
(draft.agent_workspace_dir = e.currentTarget.value)}
/></label
>
<textarea
class="a-textarea mono"
rows={10}
spellcheck="false"
value={hardeningDraft(s)}
oninput={(e) =>
(draft.agent_hardening_prompt = e.currentTarget.value)}
placeholder={t("settings.hardeningPlaceholder")}
></textarea>
<p class="field-hint">{t("settings.hardeningHint")}</p>
</label>
</section>
<section class="a-panel">
<div class="switch-row">
<div class="switch-copy">
<h2 class="a-panel-title">{t("settings.shellSection")}</h2>
<p class="a-panel-lead">{t("settings.shellLead")}</p>
<p class="mg-dim">保存新路径不会迁移已有文件。</p>
</section>
<section class="mg-field">
<header class="panel-head">
<h2 class="a-panel-title">{t("settings.agentRunSection")}</h2>
<p class="a-panel-lead">{t("settings.agentRunLead")}</p>
</header>
<div class="grid-2">
{@render numberField(s, {
key: "agent_max_steps",
labelKey: "settings.agentMaxStepsField",
hintKey: "settings.agentMaxStepsHint",
step: "1",
})}
<div class="budget">
{@render numberField(s, {
key: "agent_history_char_budget",
labelKey: "settings.historyBudgetField",
step: "1000",
})}
<div class="presets">
{#each HISTORY_BUDGET_PRESETS as preset (preset.value)}
<button
type="button"
class="preset"
class:active={historyBudgetActive(s, preset.value)}
onclick={() =>
(draft.agent_history_char_budget = String(
preset.value,
))}
>
{t(preset.labelKey)}
</button>
{/each}
</div>
</div>
</div>
<Switch
checked={boolValue(s, "agent_shell_enabled")}
label={t("settings.shellLabel")}
onchange={(next) => (draft.agent_shell_enabled = next)}
/>
</div>
<div class="note warn">
<Icon name="alert" size={14} />
<p>
<strong>{t("settings.shellCalloutStrong")}</strong>
{t("settings.shellCalloutBody")}
</p>
</div>
<p class="field-hint">{t("settings.shellHint")}</p>
</section>
<p class="field-hint">{t("settings.historyBudgetHint")}</p>
</section>
<MemoryPanel />
{:else if tab === "storage"}
<section class="a-panel">
<header class="panel-head">
<h2 class="a-panel-title">{t("settings.storageSection")}</h2>
<p class="a-panel-lead">{t("settings.storageLead")}</p>
</header>
<div class="note warn">
<Icon name="alert" size={14} />
<p>
<strong>{t("settings.storageCalloutStrong")}</strong>
{t("settings.storageCalloutBody")}
</p>
</div>
<label class="field">
<span class="field-label">{t("settings.dataDir")}</span>
<input
class="a-input mono"
value={String(fieldValue("data_dir", s.data_dir))}
oninput={(e) => (draft.data_dir = e.currentTarget.value)}
/>
<p class="field-hint">
{t("settings.current")} <code>{s.data_dir}</code>
</p>
</label>
<label class="field">
<span class="field-label">{t("settings.assetsDir")}</span>
<input
class="a-input mono"
value={String(fieldValue("assets_dir", s.assets_dir))}
oninput={(e) => (draft.assets_dir = e.currentTarget.value)}
/>
<p class="field-hint">
{t("settings.current")} <code>{s.assets_dir}</code>
</p>
</label>
<label class="field">
<span class="field-label">{t("settings.workspaceDir")}</span>
<input
class="a-input mono"
value={String(
fieldValue("agent_workspace_dir", s.agent_workspace_dir),
)}
oninput={(e) =>
(draft.agent_workspace_dir = e.currentTarget.value)}
/>
<p class="field-hint">{t("settings.workspaceDirHint")}</p>
<p class="field-hint">
{t("settings.current")} <code>{s.agent_workspace_dir}</code>
</p>
</label>
</section>
{/if}
<section class="mg-field">
<header class="panel-head">
<h2 class="a-panel-title">{t("settings.hardeningSection")}</h2>
<p class="a-panel-lead">
{t("settings.hardeningLead")}
{t("settings.hardeningCalloutStrong")}{t(
"settings.hardeningCalloutBody",
)}
</p>
</header>
<label class="field">
<span class="field-label"
>{t("settings.hardeningRulesLabel")}</span
>
<textarea
class="a-textarea mono"
rows={10}
spellcheck="false"
value={hardeningDraft(s)}
oninput={(e) =>
(draft.agent_hardening_prompt = e.currentTarget.value)}
placeholder={t("settings.hardeningPlaceholder")}
></textarea>
<p class="field-hint">{t("settings.hardeningHint")}</p>
</label>
</section>
<section class="mg-field">
<div class="switch-row">
<div class="switch-copy">
<h2 class="a-panel-title">{t("settings.shellSection")}</h2>
<p class="a-panel-lead">{t("settings.shellLead")}</p>
</div>
<Switch
checked={boolValue(s, "agent_shell_enabled")}
label={t("settings.shellLabel")}
onchange={(next) => (draft.agent_shell_enabled = next)}
/>
</div>
<div class="note warn">
<Icon name="alert" size={14} />
<p>
<strong>{t("settings.shellCalloutStrong")}</strong>
{t("settings.shellCalloutBody")}
</p>
</div>
<p class="field-hint">{t("settings.shellHint")}</p>
</section>
<MemoryPanel />
</div>
</details>
{/if}
</div>
</div>
</AppShell>
<footer class="settings-save">
<span class="mg-dim" role="status"
>{isDirty ? t("settings.unsaved") : t("settings.allSaved")}</span
><span class="mg-grow"></span><Button
variant="ghost"
disabled={!isDirty || $saveMutation.isPending}
onclick={discardDraft}>放弃修改</Button
><Button
variant="primary"
disabled={!isDirty || !isValid}
loading={$saveMutation.isPending}
onclick={() => $saveMutation.mutate()}>保存设置</Button
>
</footer>
</ManagementPage>
<ConfirmDialog
bind:open={leaveOpen}
title={t("settings.leaveTitle")}
@@ -680,228 +626,115 @@
/>
<style>
.page {
display: flex;
gap: 24px;
padding: 24px 28px 40px;
align-items: flex-start;
.settings-scroll {
flex: 1;
overflow: auto;
min-height: 0;
}
/* ---------------- section list ---------------- */
.sections {
width: 248px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
.settings-grid {
box-sizing: border-box;
position: sticky;
top: 0;
}
.sec {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 9px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-2);
font-family: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
color 150ms;
max-width: 980px;
margin: 20px auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 0 20px;
}
.sec:hover {
background: var(--surface-3);
color: var(--text);
.wide {
grid-column: 1/-1;
}
.sec.active {
background: var(--surface-3);
color: var(--text);
.advanced-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.sec-icon {
display: inline-flex;
margin-top: 2px;
flex-shrink: 0;
}
.sec.active .sec-icon {
color: var(--accent);
}
.sec-copy {
.field {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.sec-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
}
.sec-blurb {
font-size: 11px;
line-height: 1.5;
color: var(--text-3);
}
.sec-arrow {
color: var(--text-3);
font-size: 12px;
margin-top: 1px;
}
.dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
.elsewhere-head {
margin: 12px 10px 4px;
padding-top: 12px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-3);
}
/* ---------------- content ---------------- */
.content {
flex: 1;
min-width: 0;
max-width: 820px;
display: flex;
flex-direction: column;
gap: 20px;
}
.panel-head {
margin-bottom: 16px;
.field-label {
font-size: 12px;
color: var(--tx-2);
}
.field-hint {
font-size: 11px;
color: var(--tx-3);
margin: 0;
}
.field-error {
font-size: 11px;
color: var(--err);
}
.invalid {
border-color: var(--err);
}
.settings-save {
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
border-top: 1px solid var(--line);
background: var(--bg-1);
}
.advanced summary {
cursor: pointer;
font-size: 12px;
color: var(--tx-2);
padding: 12px;
}
.advanced-grid {
padding-top: 12px;
}
.panel-head h2 {
font-size: 13px;
margin: 0;
}
.a-panel-lead,
.note,
.field-hint {
font-size: 11.5px;
color: var(--tx-3);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.budget .presets {
margin: -6px 0 14px;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.switch-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
align-items: center;
gap: 12px;
}
.presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
gap: 4px;
}
/* §4.3 分段 chip: selected = amber outline + amber text + dim fill */
.preset {
padding: 3px 11px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--text-2);
font-family: inherit;
font-size: 11.5px;
cursor: pointer;
transition:
border-color 150ms,
color 150ms,
background-color 150ms;
}
.preset:hover {
border-color: var(--border-hover);
color: var(--text);
background: var(--bg-3);
border: 1px solid var(--line);
color: var(--tx-2);
border-radius: var(--r-sm);
font-size: 11px;
padding: 4px 6px;
}
.preset.active {
border-color: var(--accent);
background: var(--accent-dim);
color: var(--accent);
border-color: var(--gold);
}
.note {
.queue-fields {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 8px;
margin: 14px 0;
padding: 10px 12px;
border-radius: 8px;
border: 1px dashed var(--border-strong);
color: var(--text-2);
}
.note.warn :global(.icon) {
color: var(--warn);
flex-shrink: 0;
margin-top: 2px;
}
.note p {
margin: 0;
font-size: 12px;
line-height: 1.6;
}
.note strong {
color: var(--text);
}
.field-hint code {
color: var(--text-2);
word-break: break-all;
}
.muted {
color: var(--text-3);
}
.error-panel {
.queue-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--error);
}
.error-panel p {
margin: 0;
flex: 1;
font-size: 12.5px;
}
/* ---------------- responsive (§4.5) ---------------- */
@media (max-width: 760px) {
.page {
flex-direction: column;
padding: 18px;
}
.sections {
width: 100%;
position: static;
flex-direction: row;
flex-wrap: wrap;
}
.sec {
width: auto;
flex: 1 1 200px;
}
.elsewhere-head {
flex-basis: 100%;
margin: 6px 10px 0;
padding-top: 8px;
}
.content {
max-width: none;
width: 100%;
}
.grid-2 {
grid-template-columns: minmax(0, 1fr);
}
}
.page {
max-width: 1280px;
padding: 24px 28px 30px;
gap: 8px;
font-size: 12px;
justify-content: space-between;
}
</style>
+328 -583
View File
@@ -1,604 +1,349 @@
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import AppShell from '$lib/components/shell/AppShell.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import {
apiErrorMessage,
assetUrl,
jobsApi,
playgroundApi,
projects,
workflows,
type Job,
} from '$lib/api';
import { connectEvents } from '$lib/events';
import { parseDbTs } from '$lib/dbtime';
import { handleJobEvent, progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { isFileMissing, markOutputMissing } from '$lib/missingOutputs.svelte';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { isStandaloneJob, isWorkflowTestJob, jobSourceLabel } from '$lib/jobLabels';
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import {
apiErrorMessage,
assetUrl,
jobsApi,
projects,
workflows,
type Job,
} from "$lib/api";
import { connectEvents } from "$lib/events";
import { parseDbTs } from "$lib/dbtime";
import { handleJobEvent, progressFor } from "$lib/jobProgress";
import { jobStatusInfo } from "$lib/jobStatus";
import { isFileMissing, markOutputMissing } from "$lib/missingOutputs.svelte";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
import {
isStandaloneJob,
isWorkflowTestJob,
jobSourceLabel,
} from "$lib/jobLabels";
import { createTakeDeleteQueue } from "$lib/components/generate/takeDelete.svelte";
/**
* 生成任务页 (spec-v2 §3.5, HEL-612/654 D 批): one table summing both
* task classes — image and video — from standalone windows, the
* creation flow, and Playground. Filter chips + search; running rows
* carry a 2px progress bar; 重试 is a ghost text op (spec v2 §1.2);
* deleting one task is record-level: 200ms collapse + 5s undo Toast,
* no dialog. The real DELETE only fires after the undo window.
*/
const client = useQueryClient();
/** Three-column real tasks: source filters, queue list and details. Existing SSE, retry, cancellation and undo-delete behavior is preserved. */
const client = useQueryClient();
const jobsQuery = createQuery({
queryKey: ['jobs'],
queryFn: () => jobsApi.list(),
refetchInterval: 5_000,
});
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.listMeta });
const projectsQuery = createQuery({ queryKey: ['projects'], queryFn: projects.list });
const jobsQuery = createQuery({
queryKey: ["jobs"],
queryFn: () => jobsApi.list(),
refetchInterval: 5_000,
});
const workflowsQuery = createQuery({
queryKey: ["workflows"],
queryFn: workflows.listMeta,
});
const projectsQuery = createQuery({
queryKey: ["projects"],
queryFn: projects.list,
});
$effect(() => {
const off = connectEvents((ev) => {
handleJobEvent(ev);
if (ev.type.startsWith('job.') || ev.type === 'events.resync') {
void client.invalidateQueries({ queryKey: ['jobs'] });
void client.invalidateQueries({ queryKey: ['playground-jobs'] });
}
});
return off;
});
$effect(() => {
const off = connectEvents((ev) => {
handleJobEvent(ev);
if (ev.type.startsWith("job.") || ev.type === "events.resync") {
void client.invalidateQueries({ queryKey: ["jobs"] });
void client.invalidateQueries({ queryKey: ["playground-jobs"] });
}
});
return off;
});
type Filter = 'all' | 'image' | 'video' | 'failed' | 'active';
let filter = $state<Filter>('all');
let search = $state('');
type Filter = "all" | "image" | "video" | "failed" | "active";
let filter = $state<Filter>("all");
let search = $state("");
const deleteQueue = createTakeDeleteQueue(client);
const pendingIds = deleteQueue.pending;
$effect(() => deleteQueue.destroy);
const deleteQueue = createTakeDeleteQueue(client);
const pendingIds = deleteQueue.pending;
$effect(() => deleteQueue.destroy);
const genJobs = $derived(($jobsQuery.data ?? []).filter((j) => j.kind === 'image' || j.kind === 'video'));
const shown = $derived.by(() => {
const q = search.trim().toLowerCase();
return genJobs.filter((j) => {
if (pendingIds.has(j.id)) return false;
if (filter === 'image' || filter === 'video') {
if (j.kind !== filter) return false;
} else if (filter === 'failed' && jobStatusInfo(j).tone !== 'fail') return false;
else if (filter === 'active' && j.status !== 'pending' && j.status !== 'running') return false;
if (!q) return true;
return `${wfName(j)} ${jobSourceLabel(j)} ${j.error ?? ''} ${j.id}`.toLowerCase().includes(q);
});
});
const activeCount = $derived(genJobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
const genJobs = $derived(
($jobsQuery.data ?? []).filter(
(j) => j.kind === "image" || j.kind === "video",
),
);
const shown = $derived.by(() => {
const q = search.trim().toLowerCase();
return genJobs.filter((j) => {
if (!matchesSource(j, sourceFilter)) return false;
if (pendingIds.has(j.id)) return false;
if (filter === "image" || filter === "video") {
if (j.kind !== filter) return false;
} else if (filter === "failed" && jobStatusInfo(j).tone !== "fail")
return false;
else if (
filter === "active" &&
j.status !== "pending" &&
j.status !== "running"
)
return false;
if (!q) return true;
return `${wfName(j)} ${jobSourceLabel(j)} ${j.error ?? ""} ${j.id}`
.toLowerCase()
.includes(q);
});
});
const activeCount = $derived(
genJobs.filter((j) => j.status === "pending" || j.status === "running")
.length,
);
function wfName(job: Job): string {
return (
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
t('studio.jobName', { id: job.id })
);
}
function wfName(job: Job): string {
return (
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)
?.name ?? t("studio.jobName", { id: job.id })
);
}
function projectOf(job: Job): string {
if (isStandaloneJob(job)) return '';
const title = ($projectsQuery.data ?? []).find((p) => p.id === job.project_id)?.title;
return title ? `《${title}》` : '';
}
function projectOf(job: Job): string {
if (isStandaloneJob(job)) return "";
const title = ($projectsQuery.data ?? []).find(
(p) => p.id === job.project_id,
)?.title;
return title ? `《${title}》` : "";
}
function timeOf(job: Job): string {
const ts = parseDbTs(job.created_at);
if (!Number.isFinite(ts)) return '—';
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function timeOf(job: Job): string {
const ts = parseDbTs(job.created_at);
if (!Number.isFinite(ts)) return "—";
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 2px 进度条贴行底 — real progress from the SSE store only. */
function rowProgress(job: Job): number {
if (job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
}
function toneOf(job: Job, base: string): string {
if (base === "ok" && isFileMissing(job)) return "warn";
if (base === "queue") return "";
if (base === "fail") return "err";
return base;
}
function toneOf(job: Job, base: string): string {
if (base === 'ok' && isFileMissing(job)) return 'warn';
if (base === 'queue') return '';
if (base === 'fail') return 'err';
return base;
}
function labelOf(job: Job, base: { tone: string; label: string }): string {
return base.tone === "ok" && isFileMissing(job)
? t("jobst.missing")
: base.label;
}
function labelOf(job: Job, base: { tone: string; label: string }): string {
return base.tone === 'ok' && isFileMissing(job) ? t('jobst.missing') : base.label;
}
function requestDelete(job: Job) {
if (job.status === "pending" || job.status === "running") return;
deleteQueue.request(job, {
message: () =>
(job.output_paths?.length ?? 0) > 0
? t("studio.deletedTakeToast", { name: wfName(job) })
: t("studio.deletedTakeNoFilesToast", { name: wfName(job) }),
});
}
function requestDelete(job: Job) {
if (job.status === 'pending' || job.status === 'running') return;
deleteQueue.request(job, {
message: () =>
(job.output_paths?.length ?? 0) > 0
? t('studio.deletedTakeToast', { name: wfName(job) })
: t('studio.deletedTakeNoFilesToast', { name: wfName(job) }),
});
}
let busyId = $state<number | null>(null);
async function retry(job: Job) {
if (busyId != null) return;
busyId = job.id;
try {
await jobsApi.retry(job.id);
toast.success(t("studio.retryQueuedToast"));
await client.invalidateQueries({ queryKey: ["jobs"] });
} catch (err) {
toast.error(
t("studio.retryFailedToast", { detail: apiErrorMessage(err) }),
);
} finally {
busyId = null;
}
}
let busyId = $state<number | null>(null);
async function retry(job: Job) {
if (busyId != null) return;
busyId = job.id;
try {
await jobsApi.retry(job.id);
toast.success(t('studio.retryQueuedToast'));
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(t('studio.retryFailedToast', { detail: apiErrorMessage(err) }));
} finally {
busyId = null;
}
}
async function cancel(job: Job) {
if (busyId != null) return;
busyId = job.id;
try {
await jobsApi.cancel(job.id);
toast.info(t("studio.cancelToast"));
await client.invalidateQueries({ queryKey: ["jobs"] });
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
busyId = null;
}
}
async function cancel(job: Job) {
if (busyId != null) return;
busyId = job.id;
try {
await jobsApi.cancel(job.id);
toast.info(t('studio.cancelToast'));
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
busyId = null;
}
}
let sourceFilter = $state("all");
let activeId = $state<number | null>(null);
function matchesSource(job: Job, value: string) {
if (value === "all") return true;
if (value === "test") return isWorkflowTestJob(job);
if (isWorkflowTestJob(job)) return false;
return value === "standalone"
? isStandaloneJob(job)
: !isStandaloneJob(job);
}
const activeJob = $derived(
genJobs.find((j) => j.id === activeId && !pendingIds.has(j.id)) ?? null,
);
</script>
<AppShell active="tasks" title={t('tasks.title')} subtitle={t('tasks.subtitle')}>
{#snippet actions()}
<span class="status-chip">{t('tasks.count', { n: genJobs.length, m: activeCount })}</span>
{/snippet}
<div class="tasks-page">
<div class="toolbar">
<div class="chips" role="group" aria-label={t('tasks.title')}>
{#each [['all', t('tasks.filterAll')], ['image', t('tasks.filterImage')], ['video', t('tasks.filterVideo')], ['failed', t('tasks.filterFailed')], ['active', t('tasks.filterActive')]] as [value, label] (value)}
<button
type="button"
class="a-chip fchip"
class:sel={filter === value}
aria-pressed={filter === value}
onclick={() => (filter = value as Filter)}
>
{label}
</button>
{/each}
</div>
<label class="search">
<Icon name="search" size={13} />
<input type="search" bind:value={search} placeholder={t('tasks.searchPh')} aria-label={t('tasks.searchPh')} />
</label>
</div>
{#if !shown.length}
<div class="empty-card">
<Icon name="queue" size={22} />
<p>{search || filter !== 'all' ? t('tasks.emptyFiltered') : t('tasks.empty')}</p>
<a class="a-btn primary" href="/image">{t('tasks.goGenerate')}</a>
</div>
{:else}
<div class="task-table" role="table" aria-label={t('tasks.title')}>
{#each shown as job (job.id)}
{@const st = jobStatusInfo(job)}
{@const progress = rowProgress(job)}
<div class="trow" class:failed={st.tone === 'fail'} class:running={st.tone === 'run'}>
<span class="tkind {job.kind}">
<Icon name={job.kind === 'image' ? 'image' : 'film'} size={14} />
</span>
<div class="tthumb">
{#if job.output_paths?.[0]}
{#if job.kind === 'image'}
<img
src={assetUrl(job.output_paths[0])}
alt={wfName(job)}
loading="lazy"
onerror={() => job.output_paths[0] && markOutputMissing(job.output_paths[0])}
/>
{:else}
<video src={assetUrl(job.output_paths[0])} preload="metadata" muted></video>
{/if}
{/if}
</div>
<div class="tmain">
<div class="tname" title={wfName(job)}>
{#if isWorkflowTestJob(job)}<span class="tag-test">{t('tasks.testBadge')}</span>{/if}
{wfName(job)}
{#if job.workflow_id == null}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
</div>
<div class="tmeta mono">
{#if projectOf(job)}{projectOf(job)} · {/if}{jobSourceLabel(job)} · {timeOf(job)}
{#if (job.output_paths?.length ?? 0) > 0}
· {t('tasks.outputs', { n: job.output_paths.length })}
{/if}
</div>
{#if job.status === 'failed' && job.error}
<div class="terror">{job.error}</div>
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
<div class="terror">{st.detail}</div>
{:else if st.tone === 'ok' && isFileMissing(job)}
<div class="twarn">{t('jobst.missingDetail')}</div>
{/if}
</div>
<div class="tout">
{#if job.output_paths?.length}
<div class="tout-thumbs">
{#each job.output_paths.slice(0, 4) as p (p)}
<a
href={assetUrl(p)}
download
target="_blank"
rel="noreferrer"
title={t('tasks.download')}
>
{#if job.kind === 'image'}
<img src={assetUrl(p)} alt="" loading="lazy" />
{:else}
<video src={assetUrl(p)} preload="metadata" muted></video>
{/if}
</a>
{/each}
</div>
{/if}
</div>
<span class="a-tag tst {toneOf(job, st.tone)}" title={st.detail}>{labelOf(job, st)}</span>
<span class="tactions">
{#if (st.tone === 'fail' || (st.tone === 'ok' && isFileMissing(job))) && job.error !== 'cancelled'}
<button type="button" class="link-ghost" disabled={busyId === job.id} onclick={() => retry(job)}>
{t('tasks.retry')}
</button>
{/if}
{#if job.status === 'pending' || job.status === 'running'}
<button type="button" class="link-ghost" disabled={busyId === job.id} onclick={() => cancel(job)}>
{t('tasks.cancel')}
</button>
{/if}
{#if job.output_paths?.length}
<a class="link-ghost" href={assetUrl(job.output_paths[0])} download>
{t('tasks.download')}
</a>
{/if}
{#if isStandaloneJob(job) && job.status !== 'pending' && job.status !== 'running'}
<button
type="button"
class="link-danger"
title={t('tasks.delete')}
onclick={() => requestDelete(job)}
>
<Icon name="trash" size={13} />
</button>
{/if}
</span>
{#if progress > 0}
<div class="rowbar" style:--p={progress / 100}><i></i></div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</AppShell>
<ManagementPage active="tasks" title="生成任务">
{#snippet tools()}<div class="mg-seg task-tabs" aria-label="任务筛选">
{#each [["all", "全部"], ["active", "进行中"], ["failed", "失败"], ["image", "图片"], ["video", "视频"]] as [value, label]}<button
class:on={filter === value}
aria-pressed={filter === value}
onclick={() => (filter = value as Filter)}>{label}</button
>{/each}
</div>{/snippet}
{#snippet actions()}<label class="mg-search task-search"
><Icon name="search-manage" size={14} /><input
type="search"
bind:value={search}
placeholder="搜索任务…"
aria-label="搜索任务…"
/></label
>{/snippet}
<div class="mg-layout">
<aside class="mg-side" aria-label="任务来源">
<div class="mg-label">来源</div>
{#each [["all", "全部任务"], ["standalone", "创作台"], ["project", "项目"]] as [value, label]}<button
class="mg-filter"
class:on={sourceFilter === value}
onclick={() => (sourceFilter = value)}
>{label}<small
>{genJobs.filter((j) => matchesSource(j, value)).length}</small
></button
>{/each}
<div class="mg-label">队列</div>
<span class="mg-dim">进行中 {activeCount} 项</span>
</aside>
<div class="mg-main">
<div class="mg-table">
<table>
<colgroup
><col style="width:42%" /><col style="width:20%" /><col
style="width:20%"
/><col style="width:18%" /></colgroup
><thead
><tr
><th>任务 / 工作流</th><th>来源</th><th>进度</th><th>时间</th></tr
></thead
><tbody>
{#each shown as job (job.id)}{@const st = jobStatusInfo(job)}
<tr class:on={activeId === job.id}
><td
><button class="mg-click" onclick={() => (activeId = job.id)}
><Icon
name={job.kind === "image" ? "image" : "film-manage"}
size={14}
/><span class="mg-name"
><b>{wfName(job)}</b><small
>#{job.id}{isWorkflowTestJob(job)
? " · 工作流试跑"
: ""}</small
></span
></button
></td
><td title={projectOf(job) || jobSourceLabel(job)}
>{projectOf(job) || jobSourceLabel(job)}</td
><td
><span class="mg-tag {toneOf(job, st.tone)}" title={st.detail}
>{labelOf(job, st)}</span
>{#if job.status === "running" && progressFor(job.id)?.progress != null}<span
class="mg-dim">{progressFor(job.id)?.progress}%</span
>{/if}</td
><td class="mg-mono">{timeOf(job)}</td></tr
>
{/each}
</tbody>
</table>
{#if $jobsQuery.isLoading}<p class="mg-empty">
正在读取任务…
</p>{:else if $jobsQuery.isError}<p class="mg-empty" role="alert">
任务加载失败<Button onclick={() => $jobsQuery.refetch()}
>重试</Button
>
</p>{:else if !shown.length}<div class="mg-empty">
<p>
{search || filter !== "all"
? t("tasks.emptyFiltered")
: t("tasks.empty")}
</p>
<a class="a-btn secondary sm" href="/image">前往创作台</a>
</div>{/if}
</div>
</div>
<aside class="mg-detail" aria-label="任务详情">
{#if activeJob}{@const job = activeJob}{@const st = jobStatusInfo(job)}
<h2>{wfName(job)}</h2>
<div class="mg-mono">#{job.id} · {timeOf(job)}</div>
<div class="mg-row">
<span class="mg-tag {toneOf(job, st.tone)}">{labelOf(job, st)}</span
><span class="mg-dim">{jobSourceLabel(job)}</span>
</div>
<div class="mg-field">
<h3>运行状态</h3>
<p class="mg-dim">{job.error ?? st.detail}</p>
{#if job.status === "running"}<p class="mg-dim">
{progressFor(job.id)?.progress != null
? progressFor(job.id)?.progress + "%"
: "等待服务报告进度"}
</p>{/if}
</div>
{#if job.output_paths?.length}<div class="mg-field">
<h3>产出 · {job.output_paths.length}</h3>
{#each job.output_paths as path}<div class="task-output">
{#if job.kind === "image"}<img
src={assetUrl(path)}
alt={wfName(job)}
loading="lazy"
onerror={() => markOutputMissing(path)}
/>{:else}<video
src={assetUrl(path)}
controls
preload="metadata"><track kind="captions" /></video
>{/if}<a
class="a-btn secondary sm"
href={assetUrl(path)}
download>下载</a
>
</div>{/each}
</div>{/if}
<div class="mg-row">
{#if (st.tone === "fail" && job.error !== "cancelled") || (st.tone === "ok" && isFileMissing(job))}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => retry(job)}>重试</Button
>{/if}{#if job.status === "pending" || job.status === "running"}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => cancel(job)}>取消任务</Button
>{/if}{#if isStandaloneJob(job) && job.status !== "pending" && job.status !== "running"}<Button
size="sm"
variant="danger"
onclick={() => requestDelete(job)}>删除</Button
>{/if}
</div>
{:else}<div class="mg-empty">选择任务查看状态与产出</div>{/if}
</aside>
</div>
</ManagementPage>
<style>
.tasks-page {
padding: 20px 28px 40px;
max-width: 1560px;
width: 100%;
box-sizing: border-box;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
/* §3.5: 筛选芯片组(选中 = 金色文字 + accent-soft 底)+ 右侧搜索框 */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.fchip {
height: 28px;
}
.search {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
min-width: 0;
height: 30px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: var(--r-pill);
background: var(--bg-2);
color: var(--text-3);
}
.search:focus-within {
border-color: var(--accent);
}
.search input {
min-width: 0;
width: 180px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: 12px;
}
.search input::placeholder {
color: var(--text-3);
}
.empty-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 56px 24px;
border: 1.5px dashed var(--line-2);
border-radius: var(--r-lg);
color: var(--text-3);
}
.empty-card p {
margin: 0;
font-size: 13px;
line-height: 1.7;
}
/* 表格行:行底 --bg-2,悬停 --bg-3,进行中行底贴 2px 青进度条 */
.task-table {
display: flex;
flex-direction: column;
gap: 4px;
}
.trow {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
min-height: 56px;
}
@media (hover: hover) and (pointer: fine) {
.trow:hover {
background: var(--bg-3);
}
}
.trow.failed {
border-color: color-mix(in srgb, var(--err) 35%, transparent);
}
.rowbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
overflow: hidden;
border-radius: 0 0 var(--r-md) var(--r-md);
}
.rowbar i {
display: block;
height: 100%;
width: 100%;
transform-origin: left;
transform: scaleX(var(--p));
background: var(--run-grad);
transition: transform var(--d-progress) linear;
}
.tkind {
width: 28px;
height: 28px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-2);
background: var(--bg-4);
}
.tthumb {
width: 48px;
height: 32px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--bg-3);
flex-shrink: 0;
}
.tthumb img,
.tthumb video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tmain {
flex: 1;
min-width: 0;
}
.tname {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tname .wf-gone {
flex-shrink: 0;
}
.tmeta {
font-size: 10.5px;
color: var(--text-3);
margin-top: 1px;
}
.terror {
margin-top: 3px;
font-size: 11px;
color: var(--err);
line-height: 1.5;
}
.twarn {
margin-top: 3px;
font-size: 11px;
color: var(--warn);
line-height: 1.5;
}
.tout {
flex-shrink: 0;
}
.tout-thumbs {
display: flex;
gap: 4px;
}
.tout-thumbs a {
width: 32px;
height: 32px;
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--line);
display: block;
}
.tout-thumbs img,
.tout-thumbs video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tst {
width: 64px;
text-align: center;
flex-shrink: 0;
}
/* §1.2 ghost:次级文字操作(重试/取消/下载)文字 --text-2,悬停 --text */
.tactions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.link-ghost {
background: none;
border: none;
padding: 4px 6px;
font-family: inherit;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
border-radius: 6px;
text-decoration: none;
display: inline-flex;
align-items: center;
}
@media (hover: hover) and (pointer: fine) {
.link-ghost:hover:not(:disabled) {
color: var(--text);
}
}
.link-ghost:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.link-ghost:disabled {
opacity: 0.5;
cursor: default;
}
.link-danger {
background: none;
border: none;
padding: 4px 6px;
font-family: inherit;
font-size: 12px;
color: var(--err);
cursor: pointer;
border-radius: 6px;
opacity: 0.85;
display: inline-flex;
align-items: center;
}
@media (hover: hover) and (pointer: fine) {
.link-danger:hover:not(:disabled) {
opacity: 1;
background: var(--err-soft);
}
}
.link-danger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.link-danger:disabled {
opacity: 0.35;
cursor: default;
}
.tag-test {
flex-shrink: 0;
background: var(--bg-4);
color: var(--text-2);
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 999px;
}
/* 窄屏:表格转卡片堆叠 */
@media (max-width: 1023px) {
.tasks-page {
padding: 14px 16px 32px;
}
.trow {
flex-wrap: wrap;
row-gap: 8px;
}
.tout {
order: 5;
width: 100%;
}
.tactions {
order: 6;
margin-left: auto;
}
.tst {
order: 4;
width: auto;
margin-left: auto;
}
.search {
margin-left: 0;
width: 100%;
}
.search input {
width: 100%;
flex: 1;
}
}
.task-tabs {
width: 340px;
margin-left: 12px;
}
.task-search {
width: 240px;
}
.task-output img,
.task-output video {
width: 100%;
max-height: 300px;
object-fit: contain;
background: var(--media-bg);
}
.task-output {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
+15 -149
View File
@@ -1,152 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { workflows, settings } from '$lib/api';
import AppShell from '$lib/components/shell/AppShell.svelte';
import WorkflowBay from '$lib/components/desk/WorkflowBay.svelte';
import WorkflowLibrary from '$lib/components/workflows/WorkflowLibrary.svelte';
let manage = $state(false),
activeId = $state<number | null>(null);
const meta = createQuery({
queryKey: ['workflows-meta'],
queryFn: workflows.listMeta,
});
const config = createQuery({ queryKey: ['settings'], queryFn: settings.get });
$effect(() => {
if (page.url.searchParams.get('manage') === '1') manage = true;
});
const rows = $derived($meta.data ?? []);
function pick(id: number) {
const m = rows.find((x) => x.id === id);
if (m) goto(`/${m.kind}?wf=${id}`);
}
import { createQuery } from "@tanstack/svelte-query";
import { workflows, settings } from "$lib/api";
import WorkflowLibrary from "$lib/components/workflows/WorkflowLibrary.svelte";
const meta = createQuery({
queryKey: ["workflows", "meta"],
queryFn: workflows.listMeta,
});
const config = createQuery({ queryKey: ["settings"], queryFn: settings.get });
</script>
<AppShell active="workflows" title="工作流目录" workspace>
<div class="workflows-page">
<header>
<div>
<div class="eyebrow">YOUR CREATIVE MATERIALS</div>
<h1>工作流目录</h1>
<p>按用途挑选工具,未完成配置的工作流先到管理页核对。</p>
</div>
<button class="a-btn secondary" onclick={() => (manage = !manage)}
>{manage ? '返回工具目录' : '导入与管理'} ↗</button
>
</header>
<nav class="resource-tabs">
<a href="/library">参考素材</a><a class="active" href="/workflows"
>模型与工作流 <small>{rows.length}</small></a
>
</nav>
{#if $meta.isLoading}<p>正在打开工具目录…</p>{:else if $meta.isError}<p>
工具目录没有加载出来。<button class="a-btn secondary" onclick={() => $meta.refetch()}
>重新加载</button
>
</p>{:else if manage}
<WorkflowLibrary {rows} endpoints={$config.data?.comfyui_endpoints ?? []} />
{:else}<div class="catalogue-frame">
<WorkflowBay
catalogue
open
metas={rows}
pendingCount={rows.filter((r) => r.needs_mapping).length}
selectedId={null}
bind:activeId
endpoints={$config.data?.comfyui_endpoints ?? []}
onPick={pick}
onClose={() => {}}
/>
</div>{/if}
</div>
</AppShell>
<style>
.workflows-page {
padding: 28px 36px 56px;
max-width: 1560px;
margin: auto;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 27px;
}
.eyebrow {
font-size: 10px;
color: var(--text-3);
letter-spacing: 1.3px;
}
h1 {
margin: 8px 0 7px;
font-size: 34px;
letter-spacing: -1.3px;
font-weight: 600;
}
header p {
font-size: 11px;
color: var(--text-3);
margin: 0;
}
.resource-tabs {
display: flex;
gap: 20px;
margin-bottom: 24px;
border-bottom: 1px solid var(--line);
}
.resource-tabs a {
padding: 9px 0 12px;
font-size: 12px;
color: var(--text-3);
border-bottom: 2px solid transparent;
text-decoration: none;
}
.resource-tabs a.active {
border-color: var(--accent);
color: var(--accent);
}
small {
font-size: 10px;
margin-left: 5px;
}
.catalogue-frame {
height: calc(100vh - 305px);
min-height: 450px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
padding: 0 20px 16px;
overflow: hidden;
}
.workflows-page {
max-width: none;
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
header {
margin-bottom: 16px;
}
h1 {
font-size: 24px;
margin: 5px 0;
}
header p {
font-size: 11px;
}
.resource-tabs {
margin-bottom: 12px;
}
.catalogue-frame {
flex: 1;
height: auto;
min-height: 0;
border-radius: 10px;
}
</style>
<WorkflowLibrary
rows={$meta.data ?? []}
endpoints={$config.data?.comfyui_endpoints ?? []}
loading={$meta.isLoading}
error={$meta.isError}
onRetry={() => $meta.refetch()}
/>
+86
View File
@@ -56,6 +56,40 @@
--desk-dock-focus:
var(--sh-2), 0 0 0 3px color-mix(in srgb, var(--gold) 8%, transparent),
0 0 40px color-mix(in srgb, var(--gold) 8%, transparent);
--theme-reveal-duration: 520ms;
--theme-fallback-duration: 280ms;
--theme-preview-art: linear-gradient(160deg, #2f5d7c, #f2b36b);
--family-qwen: #8aa2ff;
--family-krea: #ff86b0;
--family-flux: #62d2ff;
--family-zimg: #a6e16a;
--family-h3: #ff9a52;
--family-wan: #c79bff;
--family-anima: #ffb86b;
--family-ink: #0c0e14;
--manage-field-radius: 10px;
--manage-card-radius: 14px;
--manage-segment-radius: 9px;
--manage-segment-option-radius: 7px;
--manage-page-background: var(--bg-0);
--manage-panel-background: var(--bg-1);
--manage-icon-stroke: 1.7;
--manage-pin-radius: 6px;
--manage-pin-size: 24px;
--manage-control-height: 32px;
--manage-control-size: 12.5px;
--manage-control-weight: 500;
--manage-control-radius: var(--r-sm);
--manage-button-height: 26px;
--manage-button-padding: 9px;
--manage-button-radius: 5px;
--manage-header-letter-spacing: normal;
--manage-filter-width: 220px;
--manage-detail-width: 360px;
--manage-media-radius: 10px;
--manage-add-min-height: 130px;
--manage-card-min: 280px;
--manage-card-padding: 14px;
--desk-head-height: var(--head);
--desk-head-inset: 16px;
--desk-head-bg: var(--glass);
@@ -291,6 +325,12 @@
--tx-inv: #141516;
--r-xs: 2px;
--r-sm: 4px;
--manage-button-radius: 5px;
--manage-header-letter-spacing: 0.2px;
--manage-field-radius: 4px;
--manage-card-radius: 4px;
--manage-segment-radius: 4px;
--manage-segment-option-radius: 4px;
--r-md: 6px;
--r-lg: 8px;
--desk-dock-radius: 8px;
@@ -372,6 +412,15 @@
--bg-3: #1f2c5c;
--bg-4: #2a386e;
--r-sm: 8px;
--manage-button-radius: 8px;
--manage-control-radius: 10px;
--manage-page-background: linear-gradient(
180deg,
#0c1435 0%,
#121640 55%,
#24163f 100%
);
--manage-panel-background: color-mix(in srgb, var(--bg-1) 78%, transparent);
--r-md: 12px;
--r-lg: 18px;
--desk-dock-radius: 18px;
@@ -607,3 +656,40 @@ html {
background: var(--bg-0);
color: var(--tx-1);
}
/* HEL-726 theme switch: the approved lamp reveal and its colour-only fallback. */
html.theme-anim,
html.theme-anim *,
html.theme-anim *::before,
html.theme-anim *::after {
transition:
background-color var(--theme-fallback-duration) var(--ease-io),
border-color var(--theme-fallback-duration) var(--ease-io),
color var(--theme-fallback-duration) var(--ease-io),
fill var(--theme-fallback-duration),
box-shadow var(--theme-fallback-duration) !important;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation: theme-lamp var(--theme-reveal-duration) var(--ease-out);
}
@keyframes theme-lamp {
from {
clip-path: circle(0 at var(--theme-x, 34px) var(--theme-y, 85%));
}
to {
clip-path: circle(150% at var(--theme-x, 34px) var(--theme-y, 85%));
}
}
:root[data-reduce="1"]::view-transition-new(root) {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-new(root) {
animation: none;
}
}
@@ -0,0 +1,45 @@
# HEL-730 · B2 管理页面施工稿
本稿尚未通过本卡的完整验收门槛。未合并、未部署、未迁移数据、未修改用户配置或密钥、未触发真实/付费生成;没有新增项目依赖。
基底精确为 `feat/hel-729-creative-desk@634965f5f51c8a1e489e6462395fc26037dcacba`,独立施工分支 `feat/hel-730-management`,PR 目标仍是该 B1 分支。原样本通过 Multica 附件下载,SHA256 为 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。依据 HEL-727 B2 边界与 HEL-735 审计执行,未启动其他批次。
## 实现
- `src/lib/components/manage/ManagementPage.svelte` / `management.css` 与 `static/themes.css`:统一管理页头、220px 筛选、弹性列表、360px 详情、表格、状态标签、模型卡片和表单。旧页面样式整体移除,不追加第二套覆盖样式。现有 `Button`、`Modal`、导入/试跑/元数据组件继续复用。
- `routes/workflows` / `WorkflowLibrary.svelte`:工作流库成为独立管理页,搜索、用途/模型族/状态/媒介/收藏筛选、真实计数、最近排序、逐批显示、单文件导入、扫描/转换/批量导入、去重、批量标注与启停、连接绑定、节点检查、输出映射与试跑入口均保留。列表使用轻量 meta,详情按需读取。修复同时预加载和打开映射时错过打开的问题,合并同条详情的并发读取,试跑前强制取得新详情,避免沿用旧绑定。
- `workflowState.ts` 统一可运行契约:`is_enabled && usage && !needs_mapping`;创作台的三处原判断改为调用同一纯函数,其他创作交互保留。
- `routes/connections`:合并 ComfyUI、大语言模型、图片模型、视频模型四页签。提取原连接/LLM 业务逻辑为 `ComfyConnections` / `LlmModels`,保留连接测试、编辑、默认项、删除、密钥留空保留和智能体模型分配。连接使用数从真实工作流的显式绑定或默认绑定计算;队列和显存没有接口则显示未知。云端图片、可灵、即梦卡片明确未接入且不能生成。`/models`、旧工作流入口兼容转向新位置。
- `routes/tasks`:按本卡及 HEL-727 的明确要求使用管理三栏;来源过滤把工作流试跑单列,不计入创作台来源。进度使用 SSE 实际值,未上报则说明等待服务;取消、重试、下载、缺失输出提示和原撤销删除队列保留。
- `routes/library`:使用素材墙,保留真实文件索引、分页、预览、选择、删除确认、存入项目。视频默认暂停,进入预览后也需手动播放。接口只提供图片/视频且没有归属字段,因此音频/未归属筛选明确禁用,项目范围显示全部,不假造归属。
- `routes/settings`:两列分组、四主题预览卡、七种真实语言、队列数值、动效开关、Dry-run、存储目录、固定保存栏;更多队列/目录/智能体选项保留在高级分组。原验证、草稿、放弃修改、离页保护及保存逻辑保留。
- `AppShell` 只增加可选页头插槽;未改导航内容。`ThemeChoices` 的新 tiles 形态仅用于设置,原主题弹层形态保留。`LanguageSwitcher` 保留原下拉形态,并增加设置页分段形态。
- 主题切换按 HEL-726 补 520ms 圆形展开、280ms 颜色回退、减少动效立即切换、连续选择以最后一次为准。仅改变根 token 和主题 store,不重新挂载编辑器。现有按钮在原统一规则中改用可选尺寸/字重 token,默认值保留 B1 最终值,管理页消费样本值。Icon 通过可选 stroke token 保留 B1 默认 1.5,仅管理页用原件 1.7,并在既有图标表中补原件的置顶、搜索、上传与映射箭头等路径,旧图标键不改;置顶复用创作台的本地 pin 存储,原后端收藏操作仍保留在详情。输入映射左侧显示真实节点类及字段,右侧显示控件名称。
上述路径除 `static` 外均相对 `calliope-web/`。
## 自测与证据
`verify-hel730.py` 只在单独的本机数据库、配置和本机假 ComfyUI 上运行,进程在脚本退出时关闭。它扫描真实示例目录,从 146 个 UI 格式文件中选取 `3-图像生成/Anima/Anima_Klein放大_超强动漫模型.json`,只转换/导入一份,确认输出节点 99,绑定假服务,检查节点后试跑。产物 SHA256、原文件 SHA256、输出隔离及创作历史排除见附件 `closed-loop.json`。假服务返回 1px 验证文件,不代表真实 GPU/模型效果,也不用于装饰产品截图。
浏览器脚本采集 5 页 × 4 主题 × 3 尺寸的 60 组同窗样本/实际截图及 DOM 实测。素材页默认空态的对照不冒充有作品状态;对照完成后才上传带 HEL730 fixture 名称的本地视频,用于播放、删除取消和存入项目空态回归。待配置工作流 fixture 同样在视觉采集之后加入,用于验证从 B1 创作台进入映射行,不属于目录导入数量。
- `npm run check`:0 errors,保留 B1 的 ShotStagePanel 字幕 warning 1 条。
- `npm run build`、前端四组守护检查通过。
- 相关后端回归 126 项通过:导入/转换、未知输出映射、试跑隔离、连接、LLM、真实状态、图输入和上传。
- 浏览器检查包含搜索、空态、导入焦点与归还、试跑入口、连接弹层关闭、四页签/旧入口、草稿保留与放弃、520ms 主题展开、减少动效、快速切换、无 View Transitions 回退、视频手动播放、删除取消、存入项目空态以及 B1 待配置入口。最终数量以附件 JSON 为准。
- 管理源码定向 Prettier 检查通过;`git diff --check` 通过。
复现:在仓库父目录用 `multica attachment download 01a15ddd-040d-7e9a-a1a3-04932a3aa0c8 -o ./attachments` 下载样本,然后从仓库根设置 `PYTHONPATH=calliope-backend/src:calliope-backend`,运行 `python3 scripts/verify-hel730.py`。浏览器工具是运行环境已有工具;可通过 `PLAYWRIGHT_MODULE` 指向它的 `index.mjs`,没有将 Playwright 加为项目依赖。生成的本机 URL 只用于脚本过程,脚本结束后不可访问,交付物是任务卡附件。
## 尚未通过的门槛
全仓 `npm run lint` 仍失败。独立导出 B1 精确基底复跑,同样存在 62 个格式文件问题,且 ESLint 9 没有任何 `eslint.config.*`,单独执行也退出 2。不能把定向格式/类型检查写成全仓 lint 已通过;未擅自跨批重排全部基线文件或增加 lint 解析器依赖,需总管明确基线工具修复的范围。
样本“动效强度”的克制档没有实际行为规则,现有偏好仅完整/减少动效;本稿把克制档诚实禁用,未自创第三套时长。需总管明确归入 B5 收尾还是补充本卡规则。任务原件的全宽表格与其组织说明不一致,本稿遵本卡/HEL-727 的明确三栏要求,证据中保留这一结构差异,不宣称整页像素相等。
仍须总工独立重拍样本/实际页面并审核;真实数据、空态、更多管理入口、继承 B1 的导航与样本的差异均可见于附件,不以功能通过代替视觉一致性,也不宣称整套样本落地。
## 回滚
本稿零后端/数据库变更。尚无部署可回滚;后续若获准部署,回退至 B1 精确提交 `634965f5f51c8a1e489e6462395fc26037dcacba` 的构建即可,保持原配置和数据。施工稿和原 B1 使用独立分支及独立 PR,不合并或改写旧 PR。
+549
View File
@@ -0,0 +1,549 @@
/** Browser evidence for a foreground, isolated HEL-730 server. */
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE || "playwright"
);
const out = process.env.HEL730_OUT,
base = process.env.HEL730_URL;
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox"],
});
const evidence = { errors: [], checks: [], measurements: [] };
const check = (condition, name) => {
evidence.checks.push({ name, pass: !!condition });
if (!condition) throw Error(name);
};
let activePage;
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: {
dir: path.join(out, "videos"),
size: { width: 1440, height: 900 },
},
});
const page = await context.newPage();
activePage = page;
page.on("pageerror", (error) => evidence.errors.push(error.message));
await page.goto(base + "/workflows");
await page.getByRole("button", { name: "试运行", exact: true }).waitFor();
await page.keyboard.press("/");
check(
await page
.locator(".workflow-search input")
.evaluate((el) => el === document.activeElement),
"workflow / focuses search",
);
await page.locator(".workflow-search input").fill("missing-result");
check(
await page.getByText("没有匹配的工作流", { exact: true }).isVisible(),
"search empty state",
);
await page.locator(".workflow-search input").fill("");
check(
(await page
.locator(".management-header .icon")
.first()
.evaluate((el) => getComputedStyle(el).strokeWidth)) === "1.7px",
"management icon stroke matches approved 1.7",
);
await page.locator(".mg-side .mg-filter").nth(1).hover();
await page.screenshot({
path: path.join(out, "interaction-filter-hover.png"),
});
await page.route("**/api/workflows/*/check", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 400));
await route.continue();
});
await page
.getByRole("button", { name: "检查节点与模型", exact: true })
.click();
await page.locator(".mg-detail .a-btn.loading").waitFor();
await page.screenshot({
path: path.join(out, "interaction-check-loading.png"),
});
check(
true,
"real node check exposes loading feedback under 400ms transport delay",
);
await page.locator(".mg-detail .a-btn.loading").waitFor({ state: "hidden" });
await page.unroute("**/api/workflows/*/check");
const pinButton = page.locator(".mg-pin").first();
await pinButton.click();
check(
(await pinButton.getAttribute("aria-pressed")) === "true",
"workflow pin is selected",
);
await page.goto(base + "/image", { waitUntil: "networkidle" });
check(
(await page
.locator(".sidebar .icon")
.first()
.evaluate((el) => getComputedStyle(el).strokeWidth)) === "1.5px",
"B1 icon stroke remains 1.5",
);
check(
await page.getByText("置顶工作流", { exact: true }).isVisible(),
"library pin is shared with B1 creation rail",
);
await page.goto(base + "/workflows", { waitUntil: "networkidle" });
await page.locator(".mg-pin").first().click();
await page.getByRole("button", { name: "批量标注", exact: true }).click();
await page
.getByRole("checkbox", { name: "全选当前列表", exact: true })
.check();
await page.getByRole("button", { name: "批量标注", exact: true }).click();
await page.getByRole("dialog").waitFor();
await page.waitForTimeout(350);
await page.screenshot({
path: path.join(out, "interaction-batch-metadata.png"),
});
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page.getByRole("button", { name: "取消选择", exact: true }).click();
check(
(await page
.getByRole("checkbox", { name: "全选当前列表", exact: true })
.count()) === 0,
"bulk entry preserves a clean default table",
);
const importButton = page.getByRole("button", {
name: "导入工作流",
exact: true,
});
await importButton.click();
await page.getByRole("dialog").waitFor();
await page.waitForTimeout(350);
await page.screenshot({
path: path.join(out, "interaction-import-open.png"),
});
for (let i = 0; i < 12; i++) await page.keyboard.press("Tab");
check(
await page
.getByRole("dialog")
.evaluate((el) => el.contains(document.activeElement)),
"import focus remains trapped",
);
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
check(
await importButton.evaluate((el) => el === document.activeElement),
"import focus returns",
);
await page
.getByRole("button", { name: "扫描 / 转换 / 批量导入", exact: true })
.click();
await page.getByRole("dialog").waitFor();
await page.waitForTimeout(350);
await page.screenshot({ path: path.join(out, "interaction-scan-open.png") });
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page.getByRole("button", { name: "试运行", exact: true }).click();
await page.getByRole("dialog").waitFor();
await page.waitForTimeout(350);
await page.screenshot({ path: path.join(out, "interaction-test-open.png") });
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page.goto(base + "/connections");
await page.getByRole("button", { name: "测试连接", exact: true }).click();
await page.getByText("连接测试通过", { exact: false }).first().waitFor();
check(true, "ComfyUI test uses real local fake HTTP");
await page.getByRole("button", { name: "编辑", exact: true }).first().click();
await page.getByRole("dialog").waitFor();
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
check(true, "ComfyUI edit modal closes and restores card");
await page
.getByRole("button", { name: "图片模型 · 后续", exact: true })
.click();
await page.waitForURL("**/connections?tab=image");
check(
await page.getByRole("button", { name: "等待接入" }).isDisabled(),
"cloud image remains disabled",
);
await page
.getByRole("button", { name: "视频模型 · 后续", exact: true })
.click();
await page.waitForURL("**/connections?tab=video");
check(
await page.getByRole("button", { name: "等待接入" }).first().isDisabled(),
"cloud video remains disabled",
);
await page.goto(base + "/models");
await page.waitForURL("**/connections?tab=llm");
await page
.getByRole("button", { name: "添加大语言模型", exact: true })
.waitFor();
check(true, "legacy models redirects to LLM tab");
await page
.getByRole("button", { name: "添加大语言模型", exact: true })
.click();
await page.getByRole("dialog").waitFor();
await page.waitForTimeout(350);
await page.screenshot({ path: path.join(out, "interaction-llm-add.png") });
await page.keyboard.press("Escape");
await page.goto(base + "/tasks");
await page.locator(".mg-table tbody button").first().click();
check(
(await page.locator(".mg-detail h2").textContent())?.length > 0,
"task details selectable",
);
await page.screenshot({
path: path.join(out, "interaction-task-detail.png"),
});
await page.goto(base + "/settings");
await page.locator(".queue-fields input").first().waitFor();
const queue = page.locator(".queue-fields input").first();
const original = await queue.inputValue();
await queue.fill(original === "2" ? "3" : "2");
await page.getByRole("button", { name: /日光棚/ }).click();
await page.waitForFunction(
() => document.documentElement.dataset.theme === "day",
);
if (await page.evaluate(() => !!document.startViewTransition)) {
await page.waitForFunction(() =>
document.getAnimations().some((a) => a.animationName === "theme-lamp"),
);
const duration = await page.evaluate(
() =>
document
.getAnimations()
.find((a) => a.animationName === "theme-lamp")
?.effect.getTiming().duration,
);
check(duration === 520, "theme lamp duration matches 520ms sample");
await page.screenshot({
path: path.join(out, "interaction-theme-reveal-frame.png"),
});
}
check(
(await queue.inputValue()) !== original,
"theme preserves unsaved settings",
);
await page.screenshot({
path: path.join(out, "interaction-settings-draft.png"),
});
await page.waitForTimeout(600);
await page.getByRole("button", { name: "放弃修改", exact: true }).click();
check(
(await queue.inputValue()) === original,
"discard settings restores draft",
);
await page
.getByRole("button", { name: "关闭(减少动效)", exact: true })
.click();
await page.getByRole("button", { name: /夜片场/ }).click();
check(
await page.evaluate(
() =>
document.documentElement.dataset.theme === "night" &&
document.documentElement.dataset.reduce === "1" &&
!document.getAnimations().some((a) => a.animationName === "theme-lamp"),
),
"reduced motion changes theme immediately",
);
await page.getByRole("button", { name: "完整", exact: true }).click();
await page.evaluate(() => {
for (const id of ["day", "bluehour", "night"])
document
.querySelector('.choices .preview[data-theme="' + id + '"]')
.closest("button")
.click();
});
await page.waitForTimeout(600);
check(
await page.evaluate(
() => document.documentElement.dataset.theme === "night",
),
"rapid theme switching keeps the final choice",
);
const advanced = page.locator(".advanced");
await advanced.locator("summary").click();
check(
await advanced.evaluate((el) => el.open),
"settings advanced controls expand",
);
await page.screenshot({
path: path.join(out, "interaction-settings-expanded.png"),
});
await advanced.locator("summary").click();
check(
!(await advanced.evaluate((el) => el.open)),
"settings advanced controls collapse",
);
// Approved sample, downloaded separately through the CLI; do not modify it.
const sampleFile = path.resolve(
"..",
"attachments",
"夜片场-多主题交互样本.html",
);
const ref = await context.newPage();
await ref.goto(pathToFileURL(sampleFile).href, {
waitUntil: "domcontentloaded",
});
const measure = async (p, selectors) =>
p.evaluate(
(selectors) =>
Object.fromEntries(
Object.entries(selectors).map(([name, selector]) => {
const el = document.querySelector(selector);
if (!el) return [name, null];
const r = el.getBoundingClientRect(),
s = getComputedStyle(el);
return [
name,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
fontSize: s.fontSize,
fontWeight: s.fontWeight,
letterSpacing: s.letterSpacing,
borderRadius: s.borderRadius,
color: s.color,
background: s.backgroundColor,
},
];
}),
),
selectors,
);
for (const [width, height] of process.env.HEL730_SKIP_VISUAL
? []
: [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await page.setViewportSize({ width, height });
await ref.setViewportSize({ width, height });
for (const theme of ["night", "day", "grade", "bluehour"]) {
for (const [route, sample] of [
["workflows", "workflows"],
["connections", "models"],
["tasks", "tasks"],
["library", "library"],
["settings", "settings"],
]) {
await page.goto(base + "/" + route, { waitUntil: "networkidle" });
await page.evaluate((theme) => {
localStorage.setItem("aigc-theme", theme);
window.dispatchEvent(
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
);
}, theme);
await ref.evaluate(
({ sample, theme }) => {
go(sample);
setTheme(theme);
},
{ sample, theme },
);
await page.waitForTimeout(600);
await page.screenshot({
path: path.join(out, `${route}-${theme}-${width}-actual.png`),
});
await ref.screenshot({
path: path.join(out, `${route}-${theme}-${width}-sample.png`),
});
const actual = await measure(page, {
header: ".management-header",
title: ".management-header>b",
side: ".mg-side",
detail: ".mg-detail",
input: ".mg-search",
button: ".management-header .a-btn",
card: ".mg-card",
seg: ".management-header .mg-seg",
tag: ".management-page .mg-tag",
});
const reference = await measure(ref, {
header: `#p-${sample} .ph`,
title: `#p-${sample} .crumb b`,
side: `#p-${sample} .mg-side`,
detail: `#p-${sample} .mg-detail`,
input: `#p-${sample} .input`,
button: `#p-${sample} .ph .btn`,
card: `#p-${sample} .mcard`,
seg: `#p-${sample} .ph .seg`,
tag: `#p-${sample} .tag`,
});
evidence.measurements.push({
route,
theme,
width,
height,
actual,
sample: reference,
documentOverflow: await page.evaluate(
() => document.documentElement.scrollWidth > innerWidth,
),
});
}
}
}
// Uploaded media is an explicitly named local fixture, added after empty-state visual captures.
const fixturePath = path.resolve(
"design/prototypes/reframe-20261014/assets/preview.webm",
);
const upload = await page.request.post(base + "/api/playground/uploads", {
multipart: {
file: {
name: "HEL730-playback-fixture.webm",
mimeType: "video/webm",
buffer: await fs.readFile(fixturePath),
},
},
});
check(upload.ok(), "isolated video fixture upload succeeds");
await page.goto(base + "/library", { waitUntil: "networkidle" });
const preview = page.getByRole("button", {
name: /预览 .*HEL730-playback-fixture/,
});
await preview.click();
const player = page.getByRole("dialog").locator("video");
await player.waitFor();
await player.evaluate(
(video) =>
new Promise((resolve) => {
if (video.readyState >= 1) resolve();
else video.addEventListener("loadedmetadata", resolve, { once: true });
}),
);
check(
await player.evaluate(
(video) => video.paused && video.controls && !video.autoplay,
),
"library video preview waits for manual play",
);
await player.evaluate((video) => video.play());
await page.waitForTimeout(300);
check(
await player.evaluate((video) => video.currentTime > 0 && !video.paused),
"library video plays on request",
);
await player.evaluate((video) => video.pause());
await page.screenshot({
path: path.join(out, "interaction-library-video-paused.png"),
});
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page.locator('.asset-meta input[type="checkbox"]').check();
await page.getByRole("button", { name: /删除已选/ }).click();
await page.getByRole("dialog").waitFor();
await page.keyboard.press("Escape");
check(
await preview.isVisible(),
"library delete cancellation retains fixture",
);
await page.getByRole("button", { name: "存入项目", exact: true }).click();
await page.getByRole("dialog").waitFor();
await page.screenshot({
path: path.join(out, "interaction-library-attach-empty.png"),
});
await page.keyboard.press("Escape");
// Test-only fixture is added after all visual snapshots; it never represents an imported directory file.
const fixture = await page.request.post(base + "/api/workflows", {
data: {
name: "HEL730 待配置入口回归",
workflow_json: {
1: {
class_type: "PrimitiveStringMultiline",
inputs: { value: "fixture" },
},
7: {
class_type: "GlaxSamplerNode",
inputs: { prompt: ["1", 0], length: 121 },
},
15: { class_type: "QuarkMuffinDisplay", inputs: { payload: ["7", 0] } },
},
usage: "text2image",
usage_source: "user",
is_enabled: false,
},
});
check(fixture.ok(), "isolated pending fixture created");
const pending = await fixture.json();
check(
pending.needs_mapping && !pending.is_enabled,
"pending fixture cannot generate",
);
// B1 compatibility: readonly desk search, pending workflow deep link, no imaginary text-to-image input.
await page.goto(base + "/image", { waitUntil: "networkidle" });
await page.keyboard.press("/");
check(
await page.evaluate(() => document.activeElement?.tagName === "INPUT"),
"B1 / search remains available",
);
await page.keyboard.type("HEL730 keyboard");
await page.keyboard.press("Escape");
await page.screenshot({ path: path.join(out, "b1-keyboard-regression.png") });
await page
.getByRole("searchbox", { name: "搜索工作流", exact: true })
.fill("");
await page.getByRole("checkbox", { name: "仅可运行", exact: true }).uncheck();
await page
.locator('button.workflow-pick[title="HEL730 待配置入口回归"]')
.click();
await page.waitForURL("**/workflows?manage=1&wf=" + pending.id);
await page.locator(".wf-map-editor").waitFor();
check(
(await page.locator(".mg-detail h2").textContent()) ===
"HEL730 待配置入口回归",
"B1 pending link opens selected mapping row",
);
await page.screenshot({
path: path.join(out, "b1-pending-mapping-regression.png"),
});
const fallback = await context.newPage();
await fallback.addInitScript(() =>
Object.defineProperty(document, "startViewTransition", {
value: undefined,
configurable: true,
}),
);
await fallback.goto(base + "/settings", { waitUntil: "networkidle" });
const previous = await fallback.evaluate(
() => document.documentElement.dataset.theme,
);
await fallback
.getByRole("button", { name: previous === "day" ? /夜片场/ : /日光棚/ })
.click();
check(
await fallback.evaluate(() =>
document.documentElement.classList.contains("theme-anim"),
),
"unsupported View Transitions uses colour fallback",
);
await fallback.waitForTimeout(400);
check(
!(await fallback.evaluate(() =>
document.documentElement.classList.contains("theme-anim"),
)),
"fallback cleans up after 340ms",
);
await fallback.screenshot({
path: path.join(out, "interaction-theme-fallback.png"),
});
await context.close();
evidence.videos = {
actual: path.basename(await page.video().path()),
reference: path.basename(await ref.video().path()),
};
check(evidence.errors.length === 0, "no browser runtime errors");
} catch (error) {
await activePage?.screenshot({
path: path.join(out, "verification-failure.png"),
});
throw error;
} finally {
await fs.writeFile(
path.join(out, "browser-evidence.json"),
JSON.stringify(evidence, null, 2),
);
await browser.close();
}
+118
View File
@@ -0,0 +1,118 @@
"""Reproduce HEL-730 on an isolated database and HTTP fake, never on a user server.
Run from repo root: PYTHONPATH=calliope-backend/src:calliope-backend python3 scripts/verify-hel730.py
Optional: PLAYWRIGHT_MODULE=/absolute/path/to/playwright/index.mjs.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
from pathlib import Path
import socket
import subprocess
import threading
import time
import uuid
import httpx
import uvicorn
from PIL import Image
from calliope.config import settings
import calliope.config as config_module
from calliope.db import migrate_db
from calliope.main import create_app
from tests.test_studio_generation import fake_comfy, PNG_1PX
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parent / 'hel730-evidence'
OUT.mkdir(exist_ok=True)
DATA = OUT / ('isolated-data-'+uuid.uuid4().hex[:8])
DATA.mkdir(exist_ok=True)
settings.data_dir = DATA
settings.assets_dir = DATA / 'assets'
settings.dry_run = False
settings.queue_poll_interval_sec = .5
settings.queue_poll_timeout_sec = 10
settings.llm_profiles = []
settings.llm_active_id = None
settings.comfyui_endpoints = []
settings.comfyui_active_id = None
config_module.CONFIG_FILE = DATA / 'isolated-config.json'
# Configuration and queue data are local to this evidence run.
asyncio.run(migrate_db(settings.db_path))
fake_generator = fake_comfy.__wrapped__()
fake = next(fake_generator)
sock = socket.socket()
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
server = uvicorn.Server(uvicorn.Config(create_app(ROOT/'calliope-web'/'build'), log_level='warning'))
thread = threading.Thread(target=lambda: server.run(sockets=[sock]), daemon=True)
thread.start()
try:
with httpx.Client(base_url=f'http://127.0.0.1:{port}', timeout=30) as client:
for _ in range(200):
if server.started: break
time.sleep(.05)
assert server.started
def request(method, url, **kwargs):
r = client.request(method, url, **kwargs)
assert r.is_success, (url, r.status_code, r.text)
return r.json()
endpoint = request('POST','/api/comfyui/endpoints',json={'name':'HEL-730 本机隔离假服务','base_url':f'http://127.0.0.1:{fake.server_address[1]}','auth_type':'none'})['endpoints'][0]
scan = request('POST','/api/workflows/import-scan',json={'root':str(ROOT/'example_ComfyUI_workflows'),'dry_run':True})
(OUT/'scan.json').write_text(json.dumps(scan,ensure_ascii=False,indent=2))
assert request('GET','/api/workflows?view=meta') == []
candidates = [f for f in scan['files'] if f.get('importable') and f.get('suggested',{}).get('usage') == 'text2image' and 'image' in f.get('suggested',{}).get('media_out',[])]
assert candidates, 'No convertible UI-format workflow in the real directory'
# Prefer text-only inputs so the fake run does not need synthetic user reference media.
candidates.sort(key=lambda f: (bool(f.get('suggested',{}).get('media_in')),f.get('source_path','')))
chosen = candidates[0]
path = ROOT/'example_ComfyUI_workflows'/chosen['source_path']
graph = json.loads(path.read_text())
convert = request('POST','/api/workflows/convert',json={'files':[{'source_path':chosen['source_path'],'workflow_json':graph}],'include_workflows':True})
(OUT/'convert.json').write_text(json.dumps(convert,ensure_ascii=False,indent=2))
imported = request('POST','/api/workflows/import-batch',json={'files':[{'source_path':chosen['source_path'],'workflow_json':graph}]})
(OUT/'import.json').write_text(json.dumps(imported,ensure_ascii=False,indent=2))
rows = request('GET','/api/workflows')
assert len(rows) == 1
wf = rows[0]
wid = wf['id']
outputs = wf.get('output_map') or [{'nodeId':o['nodeId'],'kind':o.get('kind') or 'image'} for o in wf['output_schema'] if o.get('kind') in ('image','video')]
assert outputs, wf
wf = request('PATCH',f'/api/workflows/{wid}',json={'usage':wf.get('usage') or 'text2image','usage_source':'user','output_map':outputs,'output_map_source':'user','endpoint_id':endpoint['id'],'is_enabled':True})
fake.forced_output_node = outputs[0]['nodeId']
fake.outputs_key = 'videos' if outputs[0]['kind']=='video' else 'images'
fake.out_name = 'hel730-isolated.png'
fake.object_info = {n['class_type']:{'input':{'required':{},'optional':{}},'output':[]} for n in wf['workflow_json'].values() if isinstance(n,dict) and n.get('class_type')}
check = request('POST',f'/api/workflows/{wid}/check',json={'endpoint_id':endpoint['id'],'refresh':True})
values = {i['nodeId']:i.get('defaultValue','HEL-730 fake-service verification') for i in wf['input_schema'] if i['kind'] in ('text','textarea','number','select')}
result = request('POST',f'/api/workflows/{wid}/test',json={'input_values':values})
jobid = result['job']['id']
deadline = time.monotonic()+30
while True:
job = request('GET',f'/api/jobs/{jobid}')
if job['status'] not in ('pending','running'): break
assert time.monotonic()<deadline, job
time.sleep(.1)
assert job['status']=='done', job
assert job['origin']=='workflow_test'
assert job['output_paths']
output = Path(job['output_paths'][0])
assert output.read_bytes()==PNG_1PX
assert '_workflow_test' in str(output)
history = request('GET','/api/playground/jobs')
assert all(j['id']!=jobid for j in history)
meta = request('GET','/api/workflows?view=meta')
assert meta[0]['is_enabled'] and meta[0]['usage'] and not meta[0]['needs_mapping']
summary = {'source_path':chosen['source_path'],'source_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'ui_format_files':scan['total'],'directory_json_files':len(list((ROOT/'example_ComfyUI_workflows').rglob('*.json'))),'imported':len(meta),'workflow_id':wid,'mapping':outputs,'check':check,'test_job_id':jobid,'test_status':job['status'],'output_sha256':hashlib.sha256(output.read_bytes()).hexdigest(),'output_isolated':True,'excluded_from_creation_history':True,'fake_prompt_requests':len(fake.prompts),'service_url':f'http://127.0.0.1:{port}'}
(OUT/'closed-loop.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
# This server only offers a fake 1px response. The UI screenshot must not pretend it is artwork.
env = {**os.environ,'HEL730_URL':f'http://127.0.0.1:{port}','HEL730_OUT':str(OUT),'HEL730_WORKFLOW':str(wid)}
subprocess.run(['node',str(ROOT/'scripts'/'verify-hel730.mjs')],env=env,check=True)
print(json.dumps(summary,ensure_ascii=False,indent=2))
finally:
server.should_exit = True
thread.join(timeout=10)
sock.close()
fake_generator.close()