diff --git a/calliope-web/src/lib/components/LanguageSwitcher.svelte b/calliope-web/src/lib/components/LanguageSwitcher.svelte index 6c48892c..c6492659 100644 --- a/calliope-web/src/lib/components/LanguageSwitcher.svelte +++ b/calliope-web/src/lib/components/LanguageSwitcher.svelte @@ -1,37 +1,56 @@ - +{#if segmented}
+ {#each languages as [code, label]}{/each} +
{:else} + +{/if} \ No newline at end of file + .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; + } + diff --git a/calliope-web/src/lib/components/desk/DeskWorkflowRail.svelte b/calliope-web/src/lib/components/desk/DeskWorkflowRail.svelte index fc11b8c2..8c9235a9 100644 --- a/calliope-web/src/lib/components/desk/DeskWorkflowRail.svelte +++ b/calliope-web/src/lib/components/desk/DeskWorkflowRail.svelte @@ -1,4 +1,5 @@ + +{#each pending.filter((item) => kind === "all" || item.kind === kind) as item}
+
+ {item.name}未接入 +
+
尚未接入
+
后续接入批次 · 当前不可用于生成
+
+ 0 个工作流使用 +
+
{/each} diff --git a/calliope-web/src/lib/components/manage/ComfyConnections.svelte b/calliope-web/src/lib/components/manage/ComfyConnections.svelte new file mode 100644 index 00000000..a13c49e9 --- /dev/null +++ b/calliope-web/src/lib/components/manage/ComfyConnections.svelte @@ -0,0 +1,645 @@ + + +{#if $settingsQuery.isLoading}

+ 正在读取连接… +

{:else if $settingsQuery.isError}{:else} +
+ {#each endpoints as ep (ep.id)}{@const rec = tests[ep.id]} +
+
+ {ep.name}{rec?.ms != null ? Math.round(rec.ms) + "ms" : "—"} +
+
{ep.base_url}
+
+ {rec?.ok === false + ? "连接失败 · " + (rec.reason ?? "原因未知") + : rec?.ok + ? "连接测试通过" + : "尚未测试"} · 队列未知 · 显存未知 +
+
+ {workflowsBound(ep.id)} 个工作流使用 +
+
+ {/each} + + +
{/if} + + + $deleteMutation.mutate()} + oncancel={() => (deleteTarget = null)} +/> diff --git a/calliope-web/src/lib/components/manage/FamilyBadge.svelte b/calliope-web/src/lib/components/manage/FamilyBadge.svelte new file mode 100644 index 00000000..7f0117d2 --- /dev/null +++ b/calliope-web/src/lib/components/manage/FamilyBadge.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/calliope-web/src/lib/components/manage/LlmModels.svelte b/calliope-web/src/lib/components/manage/LlmModels.svelte new file mode 100644 index 00000000..50f3ec7a --- /dev/null +++ b/calliope-web/src/lib/components/manage/LlmModels.svelte @@ -0,0 +1,833 @@ + + +{#if $settingsQuery.isLoading}

+ 正在读取模型… +

{:else if !$settingsQuery.data}{:else}{@const s = $settingsQuery.data}{@const profiles = s.llm_profiles} +
+ {#each profiles as profile (profile.id)}{@const rec = tests[profile.id]} +
+
+ {profile.name}{rec?.ms != null ? Math.round(rec.ms) + "ms" : "—"} +
+
{profile.base_url}
+
{profile.model}
+
+ {rec?.ok === false + ? (rec.reason ?? "连接失败") + : rec?.ok + ? "连接测试通过" + : "尚未测试"} +
+
+ {agentsUsing(s, profile.id)} 个智能体使用 +
+
{/each} +
+
+
+

{t("models.agentsTitle")}

+ {t("models.agentsHint")} +
+
+ {#each AGENTS as agent (agent.role)} + + {/each} +
+
+
+
{/if} + + $deleteMutation.mutate()} + oncancel={() => (deleteTarget = null)} +/> diff --git a/calliope-web/src/lib/components/manage/ManagementPage.svelte b/calliope-web/src/lib/components/manage/ManagementPage.svelte new file mode 100644 index 00000000..820015e0 --- /dev/null +++ b/calliope-web/src/lib/components/manage/ManagementPage.svelte @@ -0,0 +1,40 @@ + + + + {#snippet pageHeader()} +
+ {title}{@render tools?.()}{@render actions?.()} +
+ {/snippet} +
+ {@render children()} +
+
diff --git a/calliope-web/src/lib/components/manage/management.css b/calliope-web/src/lib/components/manage/management.css new file mode 100644 index 00000000..b874db65 --- /dev/null +++ b/calliope-web/src/lib/components/manage/management.css @@ -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; +} diff --git a/calliope-web/src/lib/components/shell/AppShell.svelte b/calliope-web/src/lib/components/shell/AppShell.svelte index 2fa4e359..69ea8105 100644 --- a/calliope-web/src/lib/components/shell/AppShell.svelte +++ b/calliope-web/src/lib/components/shell/AppShell.svelte @@ -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 @@ > -
-
- -
- {#if currentNav?.id === "connections"} - ComfyUI 连接减少动效 - 大语言模型 - {/if} - {#if scopeProjectId}{/if} - {#if actions}
{@render actions()}
{/if} - - - -
+ + + {/if}
{@render children?.()}
diff --git a/calliope-web/src/lib/components/shell/ThemeChoices.svelte b/calliope-web/src/lib/components/shell/ThemeChoices.svelte index fe2584ea..09986008 100644 --- a/calliope-web/src/lib/components/shell/ThemeChoices.svelte +++ b/calliope-web/src/lib/components/shell/ThemeChoices.svelte @@ -1,6 +1,9 @@ -
+
{#each THEMES as item} @@ -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; + } diff --git a/calliope-web/src/lib/components/ui/Icon.svelte b/calliope-web/src/lib/components/ui/Icon.svelte index acd7024b..5185957d 100644 --- a/calliope-web/src/lib/components/ui/Icon.svelte +++ b/calliope-web/src/lib/components/ui/Icon.svelte @@ -1,37 +1,37 @@ {#if content} - + {/if} diff --git a/calliope-web/src/lib/components/ui/icons.ts b/calliope-web/src/lib/components/ui/icons.ts index 59260404..d469b93f 100644 --- a/calliope-web/src/lib/components/ui/icons.ts +++ b/calliope-web/src/lib/components/ui/icons.ts @@ -4,76 +4,81 @@ * Each entry is SVG inner content rendered by Icon.svelte via {@html}. */ export const icons = { - // Navigation - home: '', - story: - '', - assets: - '', - script: - '', - video: - '', - queue: - '', - settings: - '', - playground: - '', - // Actions - plus: '', - link: '', - 'link-off': - '', - trash: - '', - edit: '', - retry: - '', - close: '', - download: - '', - upload: - '', - paperclip: - '', - copy: - '', - search: '', - 'external-link': - '', - 'zoom-in': - '', - // Media transport - play: '', - pause: - '', - stop: '', - // Status - check: '', - star: '', - alert: - '', - info: '', - clock: '', - sparkle: - '', - // Media - image: - '', - film: '', - music: - '', - folder: - '', - file: '', - // Chevrons - 'chevron-left': '', - 'chevron-right': '', - 'chevron-up': '', - 'chevron-down': '', - // Misc - drag: '', + "search-manage": '', + "upload-manage": '', + "film-manage": + '', + "arrow-right": '', + pin: '', + // Navigation + home: '', + story: + '', + assets: + '', + script: + '', + video: + '', + queue: + '', + settings: + '', + playground: + '', + // Actions + plus: '', + link: '', + "link-off": + '', + trash: + '', + edit: '', + retry: + '', + close: '', + download: + '', + upload: + '', + paperclip: + '', + copy: '', + search: '', + "external-link": + '', + "zoom-in": + '', + // Media transport + play: '', + pause: + '', + stop: '', + // Status + check: '', + star: '', + alert: + '', + info: '', + clock: '', + sparkle: + '', + // Media + image: + '', + film: '', + music: + '', + folder: + '', + file: '', + // Chevrons + "chevron-left": '', + "chevron-right": '', + "chevron-up": '', + "chevron-down": '', + // Misc + drag: '', } as const satisfies Record; export type IconName = keyof typeof icons; diff --git a/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte b/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte index cde8a9c0..16c48b2b 100644 --- a/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte +++ b/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte @@ -1,1370 +1,1108 @@ - -
-
-

{t('conn.wfTitle')}

- {t('wl.hint')} - - - - - -
- - {#if rows.length === 0} - -

{t('conn.missingMaterial')}

- {:else} -

- {t('conn.wfCount', { - n: rows.length, - m: rows.filter((r) => r.kind === 'image').length, - k: rows.filter((r) => r.kind === 'video').length, - })} - {#if pendingCount} - · {t('conn.wfPendingCount', { n: pendingCount })} - {/if} - {#if favCount} - · {t('wl.favCount', { n: favCount })} - {/if} -

- -
-
- - (search = e.currentTarget.value)} - placeholder={t('wl.searchPh')} - aria-label={t('wl.searchPh')} - /> - / -
-
- - - - - - -
-
- - - - - {#if hasActiveFilter} - - {/if} -
-
- - {#if filtered.length === 0} - - {:else} -

{t('wl.resultCount', { n: filtered.length, m: rows.length })}

- - {#if selected.size > 0} - - {/if} - -
- toggleSelectVisible()} - /> - {t('common.name')} - {t('wl.headMeta')} - {t('wl.headActions')} -
- -
- {#each visible as row (row.id)} - {@const full = fullCache[row.id]} -
- toggleRow(row.id, e.currentTarget.checked)} - /> - - - - - - {#if row.category} - {categoryLabel(row.category)} - {/if} - {#if row.model_family} - {row.model_family} - {/if} - {#if (row.media_io?.in ?? []).length || (row.media_io?.out ?? []).length} - - {/if} - {#if row.usage} - - {usageLabel(row.usage)} - - {:else} - - {/if} - {#if rowNeedsOutputMap(row)} - - {/if} - {#if !row.is_enabled && row.usage && !rowNeedsOutputMap(row)} - {t('conn.wfDisabledBadge')} - {/if} - {#if full && (full.warnings ?? []).some((w) => w.level === 'error')} - w.level === 'error').map((w) => w.message).join('\n')}> - {t('conn.wfBrokenBadge')} - - {/if} - - - { - if (next) void workflows.update(row.id, { is_enabled: true }).then( - () => client.invalidateQueries({ queryKey: ['workflows'] }), - (err) => toast.error(apiErrorMessage(err)), - ); - else void workflows.update(row.id, { is_enabled: false }).then( - () => client.invalidateQueries({ queryKey: ['workflows'] }), - (err) => toast.error(apiErrorMessage(err)), - ); - }} - /> - {#if !row.usage || rowNeedsOutputMap(row)} - ? - {/if} - - - - - - -
- {@const lastLine = lastTestLine(full)} - {@const used = row.last_used_at ? `${t('wl.lastUsed')}:${formatDbTime(row.last_used_at)}` : null} - {@const recheck = recheckLine(row)} - {#if lastLine || used || recheck} -
- {#if used}{used}{/if} - {#if lastLine}{t('conn.wfLastTest')}:{lastLine}{/if} - {#if recheck} - - {recheck.text} - - {/if} -
- {/if} - {#if wfMapEditId === row.id && full} -
-

{t('conn.wfOutputHint')}

-
- {#each (full.output_schema ?? []).filter((o) => o.evidence === 'unknown_node') as c (c.nodeId)} -
- - -
- {/each} -
-
- - -
-
- {/if} - {/each} -
- {#if filtered.length > visibleCount} - - {/if} - {/if} - {/if} -
- + + {#snippet tools()}已导入 {rows.length} 个 · 已配置可运行 {rows.filter(isWorkflowRunnable) + .length} 个{/snippet} + {#snippet actions()}{/snippet} +
+ +
+
+ +
+ {#each [["all", "全部"], ["enabled", "可运行"], ["pending", "待配置"], ["problem", "有问题"]] as [value, label]}{/each} +
+ +
+ {#if bulkMode}
+ 已选 {selected.size} 项 +
{/if} +
+ + + {#each visible as row (row.id)}{@const state = workflowState(row)} + + + + + + {/each} + +
{#if bulkMode}{/if} 名称用途模型族需要的素材状态
+ {#if bulkMode} + toggleRow(row.id, e.currentTarget.checked)} + />{/if} +
{usageLabel(row.usage)}{row.model_family ?? "—"}{row.media_io.in.length + ? row.media_io.in.map(mediaTitle).join(" · ") + : "仅提示词"}{state.label}
+ {#if loading}

正在读取工作流…

{:else if error}{:else if !visible.length}

+ {rows.length + ? "没有匹配的工作流" + : "尚未导入工作流;扫描目录不会自动启用。"} +

{/if} + {#if filtered.length > visibleCount}{/if} +
+
+ +
+
client.invalidateQueries({ queryKey: ['workflows'] })} + bind:open={batchOpen} + {endpoints} + onimported={() => invalidateWorkflows()} /> client.invalidateQueries({ queryKey: ['workflows'] })} + bind:open={singleOpen} + {endpoints} + onchanged={() => invalidateWorkflows()} /> { - client.invalidateQueries({ queryKey: ['workflows'] }); - if (testWf) { - delete fullCache[testWf.id]; - void ensureFull(testWf.id); - } - }} + bind:open={testOpen} + workflow={testWf} + {endpoints} + onchanged={() => { + invalidateWorkflows(); + if (testWf) { + delete fullCache[testWf.id]; + void ensureFull(testWf.id); + } + }} /> $dedupeMutation.mutate()} + bind:open={dedupeOpen} + title={t("wl.dedupeTitle")} + message={t("wl.dedupeConfirm")} + confirmLabel={t("wl.dedupe")} + danger + onconfirm={() => $dedupeMutation.mutate()} /> diff --git a/calliope-web/src/lib/theme.ts b/calliope-web/src/lib/theme.ts index a6831a37..34cf936e 100644 --- a/calliope-web/src/lib/theme.ts +++ b/calliope-web/src/lib/theme.ts @@ -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 | 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); } } diff --git a/calliope-web/src/lib/workflowState.ts b/calliope-web/src/lib/workflowState.ts new file mode 100644 index 00000000..68ad7b45 --- /dev/null +++ b/calliope-web/src/lib/workflowState.ts @@ -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, +): 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" }; +} diff --git a/calliope-web/src/routes/app.css b/calliope-web/src/routes/app.css index 874cf09e..adfd55ad 100644 --- a/calliope-web/src/routes/app.css +++ b/calliope-web/src/routes/app.css @@ -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; diff --git a/calliope-web/src/routes/connections/+page.svelte b/calliope-web/src/routes/connections/+page.svelte index c1ee8460..57bc7148 100644 --- a/calliope-web/src/routes/connections/+page.svelte +++ b/calliope-web/src/routes/connections/+page.svelte @@ -1,1296 +1,60 @@ - - {#snippet actions()} - {#if !$settingsQuery.isLoading && $settingsQuery.data} - {@const passing = endpoints.filter((ep) => tests[ep.id]?.ok).length} - - {t('conn.statusCount', { n: endpoints.length, m: passing })} - - {/if} - - {/snippet} - -
- {#if $settingsQuery.isLoading} -

{t('conn.loading')}

- {:else if !$settingsQuery.data} -

{t('conn.loadFailed')}

- {:else} -
-
- {#if endpoints.length === 0} - - {:else} -
- {#each endpoints as ep (ep.id)} - {@const isActive = ep.id === activeId} - {@const rec = tests[ep.id]} - {@const kind = classifyEndpoint(ep.base_url)} - {@const pending = pendingTests[ep.id] === true} - {@const bound = workflowsBound(ep.id)} -
-
- - {ep.name} - {endpointTypeLabel(kind)} - {#if ep.auth_type !== 'none'} - {ep.auth_type === 'bearer' ? 'Bearer' : 'Header'} - {/if} - {#if isActive} - {t('conn.defaultBadge')} - {/if} - - - - {#if isActive} - - {:else} - - {/if} - -
-

{ep.base_url}

-
- {#if pending} - - {t('conn.testing')} - {:else if rec?.ok} - - {t('conn.statusConnected')} - - {t('conn.latency', { ms: rec.ms ?? '?' })}{#if rec.version} - · {t('conn.version', { v: rec.version })}{/if}{#if bound > 0} - · {t('conn.boundWorkflows', { n: bound })}{/if} - - {:else if rec?.ok === false} - - {t('conn.statusFailed')} - - {t('conn.lastTestAt', { time: formatTime(rec.at) })} - - {:else} - - {t('conn.statusUntested')} - {/if} -
- {#if rec?.ok === false && rec.reason} -
- -

{rec.reason}

-
- {/if} - {#if !isActive} -
- -
- {/if} -
- {/each} -
- {/if} - -
- -

- {t('conn.noticeStrong')}{t('conn.noticeBody')} -

-
- - -
- - {#if formMode !== 'idle'} - - - {/if} -
- - - {#if endpoints.length > 0} -
-

{t('conn.targetsTitle')}

- {#each targetRows as row (row.key)} -
- {/each} -

{t('conn.targetNote')}

-
- {/if} -
-
- {/if} -
- - - $deleteMutation.mutate()} - oncancel={() => (deleteTarget = null)} -/> + + {#snippet tools()}
+ {#each [["comfy", "ComfyUI 连接"], ["llm", "大语言模型"], ["image", "图片模型 · 后续"], ["video", "视频模型 · 后续"]] as [value, label]}{/each} +
{/snippet} + {#snippet actions()}{/snippet} + {#if tab === "comfy"}{:else if tab === "llm"}{:else}
+ +
{/if} +
+ .model-tabs { + width: 420px; + margin-left: 12px; + } + diff --git a/calliope-web/src/routes/library/+page.svelte b/calliope-web/src/routes/library/+page.svelte index efff114f..f75b9333 100644 --- a/calliope-web/src/routes/library/+page.svelte +++ b/calliope-web/src/routes/library/+page.svelte @@ -1,518 +1,275 @@ - - {#snippet actions()} - - {t('library.itemCount', { count: filtered.length })} - - {#if selectedCount > 0} - - {/if} - {/snippet} - -
-
-
YOUR CREATIVE MATERIALS
-

创作的原材料。

-

让每一次探索,留下可继续创作的线索。

-
- -
-
- - - -
- -
- - {#if $mediaQuery.isLoading} -
- {#each Array(8) as _, i (i)} -
- {/each} -
- {:else if $mediaQuery.isError} - - {#snippet action()} - - {/snippet} - - {:else if filtered.length === 0} - - {:else} -
- {#each visible as item (item.path)} -
- - -
- {item.name} - {item.kind} · {fmtSize(item.size)} -
-
- -
-
- {/each} -
- {#if hasMore} - -
- {#if loadingMore} - {t('library.loadingMore')} - {:else} - - {/if} -
- {/if} - {/if} -
-
+ + {#snippet tools()}
+ {#each [["all", "全部"], ["image", "图片"], ["video", "视频"]] as [value, label]}{/each} +
{/snippet} + {#snippet actions()} + {filtered.length} 项素材 + 项目:全部{#if selectedCount}{/if}{/snippet} + {#if $mediaQuery.isLoading}

+ 正在读取素材… +

{:else if $mediaQuery.isError}{:else if !visible.length}

+ 还没有素材。创作台生成的作品会在这里保留。 +

{:else} +
+ {#each visible as item (item.path)}
+ +
+ +
+
{/each} + {#if hasMore}
+ +
{/if} +
{/if} +
{#if preview} - (preview = null)} - /> + (preview = null)} + /> {/if} (attachItem = null)} + open={attachItem !== null} + title={t("library.addToProject")} + onclose={() => (attachItem = null)} > - {#if attachItem} - - - {/if} + {#if attachItem} + + + {/if} (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)} /> diff --git a/calliope-web/src/routes/models/+page.svelte b/calliope-web/src/routes/models/+page.svelte index 5b990dcb..b7c6abf3 100644 --- a/calliope-web/src/routes/models/+page.svelte +++ b/calliope-web/src/routes/models/+page.svelte @@ -1,1626 +1,7 @@ - - {#snippet actions()} - {#if !$settingsQuery.isLoading && $settingsQuery.data} - {@const profiles = $settingsQuery.data.llm_profiles} - {@const availableCount = profiles.filter((p) => tests[p.id]?.ok).length} - - {t('models.statusCount', { n: profiles.length, m: availableCount })} - - {/if} - - {/snippet} - -
- {#if $settingsQuery.isLoading} -

{t('models.loading')}

- {:else if !$settingsQuery.data} -

{t('models.loadFailed')}

- {:else} - {@const s = $settingsQuery.data} - {@const profiles = s.llm_profiles} - {@const activeId = s.llm_active_id ?? profiles[0]?.id ?? ''} - -
- - {t('models.tabLlm')} - {profiles.length} - - - {t('models.tabImage')} - {t('models.comingSoon')} - - - {t('models.tabVideo')} - {t('models.comingSoon')} - -
- -
-
- {#if profiles.length === 0} - - {:else} -
- {#each profiles as profile (profile.id)} - {@const isActive = profile.id === activeId} - {@const rec = tests[profile.id]} - {@const kind = classifyEndpoint(profile.base_url)} - {@const pending = pendingTests[profile.id] === true} - {@const using = agentsUsing(s, profile.id)} -
-
- - {profile.name} - {endpointTypeLabel(kind)} - {#if isActive} - {t('models.defaultBadge')} - {/if} - - - - {#if isActive} - - {:else} - - {/if} - -
-

- {profile.base_url} · {profile.model} -

-
- {#if pending} - - {t('models.testing')} - {:else if rec?.ok} - - {t('models.statusConnected')} - - {t('models.latency', { - ms: rec.ms ?? '?', - })}{#if using > 0} - · {t('models.agentsUsing', { n: using })}{/if} - - {:else if rec?.ok === false} - - {t('models.statusFailed')} - - {t('models.lastTestAt', { - time: formatTime(rec.at), - })}{#if rec.fails >= 2} - · {t('models.consecutiveFails', { - n: rec.fails, - })}{/if} - - {#if rec.fails >= 3} - {t('models.cloudPaused')} - {/if} - {:else} - - {t('models.statusUntested')} - {/if} -
- {#if rec?.ok === false && rec.reason} -
- -

{rec.reason}

-
- {/if} - {#if !isActive} -
- -
- {/if} -
- {/each} -
- {/if} - -
- -

- {t('models.noticeStrong')}{t( - 'models.noticeBody', - )} -

-
- -
-
-

{t('models.agentsTitle')}

- {t('models.agentsHint')} -
-
- {#each AGENTS as agent (agent.role)} - - {/each} -
-
-
- - -
- {/if} -
-
- - $deleteMutation.mutate()} - oncancel={() => (deleteTarget = null)} -/> - - +

正在打开模型与连接…

diff --git a/calliope-web/src/routes/settings/+page.svelte b/calliope-web/src/routes/settings/+page.svelte index 3e7a1763..abbf11bb 100644 --- a/calliope-web/src/routes/settings/+page.svelte +++ b/calliope-web/src/routes/settings/+page.svelte @@ -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 = { llm: "/models", - workflows: "/connections", + workflows: "/workflows", skills: "/agents", }; @@ -125,24 +101,6 @@ ); const isDirty = $derived(dirtyKeys.length > 0); - const FIELD_SECTION: Record = { - 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 @@ - {#snippet numberField(s: Settings, f: NumField)} {@const limits = NUMERIC_LIMITS[f.key]} {/snippet} - - {#snippet actions()} - - {#if isDirty}{/if} - {isDirty ? t("settings.unsaved") : t("settings.allSaved")} - - - - {/snippet} - -
- - -
- {#if tab === "appearance"} -
-
-

外观主题

-

同一个片场,换一种灯光。

-
- -
- {:else if $settingsQuery.isLoading} -

{t("settings.loading")}

- {:else if $settingsQuery.isError} - + {/if}
-
- +
+ {isDirty ? t("settings.unsaved") : t("settings.allSaved")} +
+ diff --git a/calliope-web/src/routes/tasks/+page.svelte b/calliope-web/src/routes/tasks/+page.svelte index 8e836691..e63c6f71 100644 --- a/calliope-web/src/routes/tasks/+page.svelte +++ b/calliope-web/src/routes/tasks/+page.svelte @@ -1,604 +1,349 @@ - - {#snippet actions()} - {t('tasks.count', { n: genJobs.length, m: activeCount })} - {/snippet} - -
-
-
- {#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)} - - {/each} -
- -
- - {#if !shown.length} -
- -

{search || filter !== 'all' ? t('tasks.emptyFiltered') : t('tasks.empty')}

- {t('tasks.goGenerate')} -
- {:else} -
- {#each shown as job (job.id)} - {@const st = jobStatusInfo(job)} - {@const progress = rowProgress(job)} -
- - - -
- {#if job.output_paths?.[0]} - {#if job.kind === 'image'} - {wfName(job)} job.output_paths[0] && markOutputMissing(job.output_paths[0])} - /> - {:else} - - {/if} - {/if} -
-
-
- {#if isWorkflowTestJob(job)}{t('tasks.testBadge')}{/if} - {wfName(job)} - {#if job.workflow_id == null}{t('studio.wfDeletedTag')}{/if} -
-
- {#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} -
- {#if job.status === 'failed' && job.error} -
{job.error}
- {:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')} -
{st.detail}
- {:else if st.tone === 'ok' && isFileMissing(job)} -
{t('jobst.missingDetail')}
- {/if} -
-
- {#if job.output_paths?.length} -
- {#each job.output_paths.slice(0, 4) as p (p)} - - {#if job.kind === 'image'} - - {:else} - - {/if} - - {/each} -
- {/if} -
- {labelOf(job, st)} - - {#if (st.tone === 'fail' || (st.tone === 'ok' && isFileMissing(job))) && job.error !== 'cancelled'} - - {/if} - {#if job.status === 'pending' || job.status === 'running'} - - {/if} - {#if job.output_paths?.length} - - {t('tasks.download')} - - {/if} - {#if isStandaloneJob(job) && job.status !== 'pending' && job.status !== 'running'} - - {/if} - - {#if progress > 0} -
- {/if} -
- {/each} -
- {/if} -
-
+ + {#snippet tools()}
+ {#each [["all", "全部"], ["active", "进行中"], ["failed", "失败"], ["image", "图片"], ["video", "视频"]] as [value, label]}{/each} +
{/snippet} + {#snippet actions()}{/snippet} +
+ +
+
+ + + {#each shown as job (job.id)}{@const st = jobStatusInfo(job)} + + {/each} + +
任务 / 工作流来源进度时间
{projectOf(job) || jobSourceLabel(job)}{labelOf(job, st)}{#if job.status === "running" && progressFor(job.id)?.progress != null}{progressFor(job.id)?.progress}%{/if}{timeOf(job)}
+ {#if $jobsQuery.isLoading}

+ 正在读取任务… +

{:else if $jobsQuery.isError}{:else if !shown.length}
+

+ {search || filter !== "all" + ? t("tasks.emptyFiltered") + : t("tasks.empty")} +

+ 前往创作台 +
{/if} +
+
+ +
+
diff --git a/calliope-web/src/routes/workflows/+page.svelte b/calliope-web/src/routes/workflows/+page.svelte index 1c7958fa..0bbb96b3 100644 --- a/calliope-web/src/routes/workflows/+page.svelte +++ b/calliope-web/src/routes/workflows/+page.svelte @@ -1,152 +1,18 @@ - -
-
-
-
YOUR CREATIVE MATERIALS
-

工作流目录

-

按用途挑选工具,未完成配置的工作流先到管理页核对。

-
- -
- - {#if $meta.isLoading}

正在打开工具目录…

{:else if $meta.isError}

- 工具目录没有加载出来。 -

{:else if manage} - - {:else}
- r.needs_mapping).length} - selectedId={null} - bind:activeId - endpoints={$config.data?.comfyui_endpoints ?? []} - onPick={pick} - onClose={() => {}} - /> -
{/if} -
-
- - + $meta.refetch()} +/> diff --git a/calliope-web/static/themes.css b/calliope-web/static/themes.css index 80cc0823..c2df8bbe 100644 --- a/calliope-web/static/themes.css +++ b/calliope-web/static/themes.css @@ -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; + } +} diff --git a/docs/changelogs/2026-10-22-hel730-management.md b/docs/changelogs/2026-10-22-hel730-management.md new file mode 100644 index 00000000..354095a5 --- /dev/null +++ b/docs/changelogs/2026-10-22-hel730-management.md @@ -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。 diff --git a/scripts/verify-hel730.mjs b/scripts/verify-hel730.mjs new file mode 100644 index 00000000..c90bf5ca --- /dev/null +++ b/scripts/verify-hel730.mjs @@ -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(); +} diff --git a/scripts/verify-hel730.py b/scripts/verify-hel730.py new file mode 100644 index 00000000..f73aee08 --- /dev/null +++ b/scripts/verify-hel730.py @@ -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()