Compare commits

...
Author SHA1 Message Date
leeferandmultica-agent 2004f527cc fix(HEL-767): align management header, theme cards and shared rail with sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 06:38:32 +08:00
leeferandmultica-agent dca88b3765 fix(HEL-751): restore confirmed full-width task table
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 05:18:07 +08:00
leeferandmultica-agent 54880e900b fix: restore library entrance and document video caption exception
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 04:44:33 +08:00
leeferandmultica-agent 4c849e9ef9 docs: explain 29 scoped lint exceptions for HEL-762 batch 4
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 04:14:43 +08:00
leeferandmultica-agent 46fd5bbc86 fix: use Svelte reactive collections at isolated boundaries (HEL-760)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:51:16 +08:00
leeferandmultica-agent fc6616b118 fix: preserve resource link semantics with external rel (HEL-758)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:32:35 +08:00
leeferandmultica-agent 22f82fbed6 docs: record HEL-756 batch 1 implementation and validation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:07:08 +08:00
leeferandmultica-agent 67eb822efe fix(navigation): keep tool destinations in a module constant
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:02:47 +08:00
leeferandmultica-agent 99574c2226 fix(state): preserve readonly runes and remove inert export flag
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:22 +08:00
leeferandmultica-agent 88965fa70d fix(lists): key 48 loops with verified identity or positional semantics
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:22 +08:00
leeferandmultica-agent 9d54b9d8dd fix(navigation): resolve the 74 approved application destinations
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:12 +08:00
leeferandmultica-agent a666f6bd12 docs: record HEL-753 joint type validation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent b02c50fdf7 fix(shot): type mirror and posture boundaries without assertions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent 7e9a5be19d fix(shot): declare real mannequin joints and numeric accessors
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent e49b8b0072 docs(HEL-752): record validation and unresolved behavioral gates
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:25:13 +08:00
leeferandmultica-agent ddc02fbe4c chore(HEL-752): retain unchanged SCSS package registry entry
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:21:00 +08:00
leeferandmultica-agent d076978a4a refactor(HEL-752): remove unused breakpoint bookkeeping
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:20:23 +08:00
leeferandmultica-agent 4e200cc8ac refactor(HEL-752): type menu and blend contracts and document local exceptions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:18:32 +08:00
leeferandmultica-agent bfcf542e59 refactor(HEL-752): remove unused locals while preserving evaluation and props
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:16:48 +08:00
leeferandmultica-agent 3d1c4ca5af fix(HEL-752): use Svelte-aware const checks and preserve legacy rules
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:15:16 +08:00
leeferandmultica-agent 24129438e8 chore(HEL-750): configure ESLint 9 and document remaining lint blockers
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:02:58 +08:00
leeferandmultica-agent 155d914df3 style(HEL-750): apply existing Prettier formatting to 61 files
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:57:34 +08:00
leeferandmultica-agent 64c1116bca fix(HEL-730): preserve shared pins and sample icon geometry
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:51:42 +08:00
leeferandmultica-agent 03bebec94a fix(HEL-730): align media header and connection add card
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:26:21 +08:00
leeferandmultica-agent 0f4d6a8961 fix(HEL-730): use approved standard button tokens
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:14:59 +08:00
leeferandmultica-agent 06c9bd5000 fix(HEL-730): match compact workflow filter sizing
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:12:34 +08:00
leeferandmultica-agent acb6beca1c fix(HEL-730): align measured control sizes and typography
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:05:33 +08:00
leeferandmultica-agent a6b04e5c91 feat(HEL-730): reshape management pages and preserve workflow tools
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:01:58 +08:00
leeferandmultica-agent 634965f5f5 HEL-743: restore search shortcut and full-width responsive desk header
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 23:26:41 +08:00
leeferandmultica-agent e62499c517 HEL-741: match runnable toggle to sample and record final regression
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 22:47:22 +08:00
leeferandmultica-agent b6b3a9fed3 HEL-741: restore workflow filters, header and result actions to sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 22:44:37 +08:00
leeferandmultica-agent 7bf63d0587 HEL-740: document existing AGit review update
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:56:46 +08:00
leeferandmultica-agent 5cb75d5718 HEL-740: restore approved creative desk dock and anchored controls
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:54:09 +08:00
leeferandmultica-agent 2304ab1538 feat(HEL-729): unify the Nocturne creative desk and isolate history
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:07:19 +08:00
leeferandmultica-agent 8e84e4c21f feat(HEL-728): establish approved themes and compatible studio navigation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 20:13:03 +08:00
202 changed files with 44155 additions and 31529 deletions
+49
View File
@@ -0,0 +1,49 @@
import js from "@eslint/js";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import svelte from "eslint-plugin-svelte";
export default [
// SvelteKit sync and adapter-static output; these are not source files.
{ ignores: [".svelte-kit/**", "build/**"] },
js.configs.recommended,
...svelte.configs["flat/recommended"],
// v3 removed these from recommended; retain the previous checks explicitly.
{
rules: {
"svelte/valid-compile": "error",
"svelte/no-dynamic-slot-name": "error",
},
},
{
files: ["**/*.{ts,svelte}"],
plugins: { "@typescript-eslint": tsPlugin },
rules: {
...tsPlugin.configs["eslint-recommended"].overrides[0].rules,
...tsPlugin.configs.recommended.rules,
},
},
{
files: ["**/*.ts"],
ignores: ["**/*.svelte.ts"],
languageOptions: { parser: tsParser },
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: { parserOptions: { parser: tsParser } },
rules: {
"prefer-const": "off",
"svelte/prefer-const": "error",
},
},
{
files: ["src/**/*.{js,ts,svelte}"],
ignores: ["src/hooks.server.ts"],
languageOptions: { globals: globals.browser },
},
{
files: ["*.{js,ts}", "scripts/**/*.mjs", "src/hooks.server.ts"],
languageOptions: { globals: globals.node },
},
];
+4511 -4256
View File
File diff suppressed because it is too large Load Diff
+39 -37
View File
@@ -1,39 +1,41 @@
{
"name": "calliope-web",
"version": "1.5.8",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.13.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-plugin-svelte": "^2.46.0",
"postcss": "^8.5.0",
"prettier": "^3.5.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.20.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tanstack/svelte-query": "^5.66.0",
"@types/three": "0.185.0",
"@xyflow/svelte": "^1.6.5",
"mannequin-js": "5.2.3",
"three": "0.185.1"
},
"type": "module"
"name": "calliope-web",
"version": "1.5.8",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.13.0",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-plugin-svelte": "3.23.1",
"postcss": "^8.5.0",
"prettier": "^3.5.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.20.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tanstack/svelte-query": "^5.66.0",
"@types/three": "0.185.0",
"@xyflow/svelte": "^1.6.5",
"mannequin-js": "5.2.3",
"three": "0.185.1"
},
"type": "module"
}
+105 -94
View File
@@ -16,51 +16,53 @@
*
* Run with `npm test` (or `node scripts/check-storage-ssr.mjs`).
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
const SRC = join(ROOT, 'src');
const HELPER = join(SRC, 'lib', 'storage.ts');
const ROOT = join(HERE, "..");
const SRC = join(ROOT, "src");
const HELPER = join(SRC, "lib", "storage.ts");
let failures = 0;
function fail(message) {
failures += 1;
console.error(`FAIL ${message}`);
failures += 1;
console.error(`FAIL ${message}`);
}
function ok(message) {
console.log(`ok ${message}`);
console.log(`ok ${message}`);
}
/** Replace the global under test. Node's own binding may be getter-only. */
function setGlobalStorage(value) {
Object.defineProperty(globalThis, 'localStorage', {
value,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "localStorage", {
value,
configurable: true,
writable: true,
});
}
/** A Storage that behaves like a browser's. */
function fakeStorage(initial = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => void map.set(k, String(v)),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => void map.set(k, String(v)),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HELPER).href);
const { storageGet, storageSet, storageRemove } = await import(
pathToFileURL(HELPER).href
);
// ---- 1. behavioural ----------------------------------------------------
@@ -68,124 +70,133 @@ const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HEL
// This is the configuration that produced the original crash.
setGlobalStorage({});
try {
if (storageGet('calliope-lang') !== null) fail('method-less localStorage: expected null');
else if (storageSet('calliope-lang', 'ja') !== undefined) fail('method-less localStorage: setItem should no-op');
else {
storageRemove('calliope-lang');
ok('method-less localStorage (Node SSR shape) degrades to null without throwing');
}
if (storageGet("calliope-lang") !== null)
fail("method-less localStorage: expected null");
else if (storageSet("calliope-lang", "ja") !== undefined)
fail("method-less localStorage: setItem should no-op");
else {
storageRemove("calliope-lang");
ok(
"method-less localStorage (Node SSR shape) degrades to null without throwing",
);
}
} catch (err) {
fail(`method-less localStorage threw: ${err.message}`);
fail(`method-less localStorage threw: ${err.message}`);
}
// Absent entirely (the common server case).
setGlobalStorage(undefined);
try {
if (storageGet('k') !== null) fail('undefined localStorage: expected null');
else ok('absent localStorage degrades to null');
if (storageGet("k") !== null) fail("undefined localStorage: expected null");
else ok("absent localStorage degrades to null");
} catch (err) {
fail(`undefined localStorage threw: ${err.message}`);
fail(`undefined localStorage threw: ${err.message}`);
}
// Access itself can throw (sandboxed frame / blocked third-party storage).
setGlobalStorage(
new Proxy(
{},
{
get() {
throw new Error('SecurityError');
},
},
),
new Proxy(
{},
{
get() {
throw new Error("SecurityError");
},
},
),
);
try {
if (storageGet('k') !== null) fail('throwing localStorage: expected null');
else ok('throwing localStorage access degrades to null');
if (storageGet("k") !== null) fail("throwing localStorage: expected null");
else ok("throwing localStorage access degrades to null");
} catch (err) {
fail(`throwing localStorage access threw: ${err.message}`);
fail(`throwing localStorage access threw: ${err.message}`);
}
// Quota / private-mode errors on write must not escape.
setGlobalStorage({
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError');
},
removeItem: () => {},
getItem: () => null,
setItem: () => {
throw new Error("QuotaExceededError");
},
removeItem: () => {},
});
try {
storageSet('k', 'v');
ok('setItem failure is swallowed');
storageSet("k", "v");
ok("setItem failure is swallowed");
} catch (err) {
fail(`setItem failure escaped: ${err.message}`);
fail(`setItem failure escaped: ${err.message}`);
}
// A real browser Storage must still work — guards against a helper that just
// returns null unconditionally to satisfy the checks above.
setGlobalStorage(fakeStorage());
try {
storageSet('calliope-lang', 'ko');
const read = storageGet('calliope-lang');
if (read !== 'ko') fail(`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`);
else {
storageRemove('calliope-lang');
if (storageGet('calliope-lang') !== null) fail('working storage remove: expected null');
else ok('working storage round-trips and removes');
}
storageSet("calliope-lang", "ko");
const read = storageGet("calliope-lang");
if (read !== "ko")
fail(
`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`,
);
else {
storageRemove("calliope-lang");
if (storageGet("calliope-lang") !== null)
fail("working storage remove: expected null");
else ok("working storage round-trips and removes");
}
} catch (err) {
fail(`working storage threw: ${err.message}`);
fail(`working storage threw: ${err.message}`);
}
// ---- 2. static ---------------------------------------------------------
const UNSAFE = /typeof\s+localStorage/;
const EXTENSIONS = ['.ts', '.js', '.svelte'];
const EXTENSIONS = [".ts", ".js", ".svelte"];
function walk(dir) {
const found = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
found.push(...walk(full));
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
found.push(full);
}
}
return found;
const found = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules") continue;
found.push(...walk(full));
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
found.push(full);
}
}
return found;
}
function exists(p) {
try {
return statSync(p).isFile();
} catch {
return false;
}
try {
return statSync(p).isFile();
} catch {
return false;
}
}
const offenders = [];
for (const file of walk(SRC)) {
if (file === HELPER) continue; // the one place allowed to probe the global
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
if (UNSAFE.test(line)) offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
});
if (file === HELPER) continue; // the one place allowed to probe the global
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, i) => {
if (UNSAFE.test(line))
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
});
}
if (offenders.length) {
fail(
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join('\n ')}`,
);
fail(
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join("\n ")}`,
);
} else {
ok('no unsafe `typeof localStorage` guards in src/');
ok("no unsafe `typeof localStorage` guards in src/");
}
if (!exists(HELPER)) fail('src/lib/storage.ts is missing');
if (!exists(HELPER)) fail("src/lib/storage.ts is missing");
// ---- result ------------------------------------------------------------
if (failures) {
console.error(`\n${failures} storage/SSR check(s) failed.`);
process.exit(1);
console.error(`\n${failures} storage/SSR check(s) failed.`);
process.exit(1);
}
console.log('\nstorage SSR checks passed.');
console.log("\nstorage SSR checks passed.");
+29 -18
View File
@@ -1,19 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<script>
(function () {
var saved;
try {
saved = localStorage.getItem("aigc-theme");
} catch (_) {}
document.documentElement.dataset.theme =
/^(night|day|grade|bluehour)$/.test(saved || "") ? saved : "night";
})();
</script>
<link rel="stylesheet" href="%sveltekit.assets%/themes.css" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+6 -6
View File
@@ -1,9 +1,9 @@
import type { HandleClientError } from '@sveltejs/kit';
import { t } from '$lib/i18n.svelte';
import type { HandleClientError } from "@sveltejs/kit";
import { t } from "$lib/i18n.svelte";
export const handleError: HandleClientError = ({ error }) => {
console.error(error);
return {
message: t('global.somethingWentWrong'),
};
console.error(error);
return {
message: t("global.somethingWentWrong"),
};
};
+2 -2
View File
@@ -1,4 +1,4 @@
export async function handle({ event, resolve }) {
const response = await resolve(event);
return response;
const response = await resolve(event);
return response;
}
+13 -13
View File
@@ -1,17 +1,17 @@
import type { Action } from 'svelte/action';
import type { Action } from "svelte/action";
/** A local honeycomb highlight follows the pointer, independent of selected state. */
export const spotlight: Action<HTMLElement> = (node) => {
const move = (event: PointerEvent) => {
if (
document.documentElement.dataset.reduce === '1' ||
event.pointerType === 'touch'
)
return;
const rect = node.getBoundingClientRect();
node.style.setProperty('--pointer-x', `${event.clientX - rect.left}px`);
node.style.setProperty('--pointer-y', `${event.clientY - rect.top}px`);
};
node.addEventListener('pointermove', move);
return { destroy: () => node.removeEventListener('pointermove', move) };
const move = (event: PointerEvent) => {
if (
document.documentElement.dataset.reduce === "1" ||
event.pointerType === "touch"
)
return;
const rect = node.getBoundingClientRect();
node.style.setProperty("--pointer-x", `${event.clientX - rect.left}px`);
node.style.setProperty("--pointer-y", `${event.clientY - rect.top}px`);
};
node.addEventListener("pointermove", move);
return { destroy: () => node.removeEventListener("pointermove", move) };
};
+51 -42
View File
@@ -2,50 +2,50 @@
* Shared types and helpers for the Agent composer (@workflow mentions + attachments).
*/
export type WorkflowKind = 'image' | 'video';
export type AttachmentKind = 'image' | 'video' | 'audio' | 'document';
export type WorkflowKind = "image" | "video";
export type AttachmentKind = "image" | "video" | "audio" | "document";
export interface WorkflowMention {
type: 'workflow';
id: number;
name: string;
kind: WorkflowKind;
type: "workflow";
id: number;
name: string;
kind: WorkflowKind;
}
export interface AgentAttachment {
path: string;
name: string;
kind: AttachmentKind;
path: string;
name: string;
kind: AttachmentKind;
}
export interface AgentComposerPayload {
content: string;
mentions: (WorkflowMention | SkillMention)[];
attachments: AgentAttachment[];
/** question/asked seq this message answers (question-card click). */
answer_to?: number;
content: string;
mentions: (WorkflowMention | SkillMention)[];
attachments: AgentAttachment[];
/** question/asked seq this message answers (question-card click). */
answer_to?: number;
}
export interface WorkflowOption {
id: number;
name: string;
kind: WorkflowKind;
description?: string | null;
is_enabled?: boolean;
id: number;
name: string;
kind: WorkflowKind;
description?: string | null;
is_enabled?: boolean;
}
export interface SkillOption {
name: string;
description: string;
tags: string[];
name: string;
description: string;
tags: string[];
}
/** A `/skill` command inserted as a chip. Serialized into the message's
* mentions payload so the backend can project it into the LLM context. */
export interface SkillMention {
type: 'skill';
name: string;
description: string;
type: "skill";
name: string;
description: string;
}
export const MAX_WORKFLOW_MENTIONS = 1;
@@ -53,27 +53,36 @@ export const MAX_WORKFLOW_MENTIONS = 1;
export const MENTION_LIMIT = 12;
/** Case-insensitive name filter of enabled workflows, capped for the typeahead. */
export function filterWorkflows(workflows: WorkflowOption[], query: string): WorkflowOption[] {
const q = query.trim().toLowerCase();
const enabled = workflows.filter((w) => w.is_enabled !== false);
const matched = q
? enabled.filter((w) => w.name.toLowerCase().includes(q))
: enabled;
return matched.slice(0, MENTION_LIMIT);
export function filterWorkflows(
workflows: WorkflowOption[],
query: string,
): WorkflowOption[] {
const q = query.trim().toLowerCase();
const enabled = workflows.filter((w) => w.is_enabled !== false);
const matched = q
? enabled.filter((w) => w.name.toLowerCase().includes(q))
: enabled;
return matched.slice(0, MENTION_LIMIT);
}
/** Case-insensitive filter of skills for the `/` typeahead (name + description). */
export function filterSkills(skills: SkillOption[], query: string): SkillOption[] {
const q = query.trim().toLowerCase();
const matched = q
? skills.filter(
(s) =>
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
)
: skills;
return matched.slice(0, MENTION_LIMIT);
export function filterSkills(
skills: SkillOption[],
query: string,
): SkillOption[] {
const q = query.trim().toLowerCase();
const matched = q
? skills.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q),
)
: skills;
return matched.slice(0, MENTION_LIMIT);
}
export function payloadIsEmpty(p: AgentComposerPayload): boolean {
return !p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0;
return (
!p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0
);
}
+13 -11
View File
@@ -7,28 +7,30 @@
* buttons stay dumb and the wording never drifts out of sync.
*/
export type AgentTaskKind = 'story' | 'script';
export type AgentTaskKind = "story" | "script";
/** The pre-filled composer prompt for each task. */
export const AGENT_TASK_PROMPTS: Record<AgentTaskKind, string> = {
story:
'Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.',
script:
'Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.',
story:
"Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.",
script:
"Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.",
};
/** i18n key for the auto-created session's sidebar title. */
export const AGENT_TASK_TITLE_KEYS: Record<AgentTaskKind, string> = {
story: 'agent.task.story',
script: 'agent.task.script',
story: "agent.task.story",
script: "agent.task.script",
};
export function isAgentTaskKind(value: string | null | undefined): value is AgentTaskKind {
return value === 'story' || value === 'script';
export function isAgentTaskKind(
value: string | null | undefined,
): value is AgentTaskKind {
return value === "story" || value === "script";
}
/** Build the /agents deep-link URL for a project-scoped task. */
export function agentDeepLink(projectId: number, task: AgentTaskKind): string {
const params = new URLSearchParams({ project: String(projectId), task });
return `/agents?${params.toString()}`;
const params = new URLSearchParams({ project: String(projectId), task });
return `/agents?${params.toString()}`;
}
+41 -31
View File
@@ -1,50 +1,60 @@
/** Shared shape for Omni / video ref pickers. */
export type AssetGroup = 'character' | 'location' | 'item' | 'upload' | 'clip' | 'shot';
export type AssetGroup =
"character" | "location" | "item" | "upload" | "clip" | "shot";
export interface AssetOption {
label: string;
path: string;
kind?: 'image' | 'video' | 'audio';
group?: AssetGroup;
label: string;
path: string;
kind?: "image" | "video" | "audio";
group?: AssetGroup;
}
const GROUP_FROM_SUFFIX: Array<{ re: RegExp; group: AssetGroup }> = [
{ re: /\s·\s*sheet$/i, group: 'character' },
{ re: /\s·\s*environment$/i, group: 'location' },
{ re: /\s·\s*item$/i, group: 'item' },
{ re: /\s·\s*upload$/i, group: 'upload' },
{ re: /\s·\s*blockout(\s+clip)?$/i, group: 'shot' },
{ re: /^clip\s*#/i, group: 'clip' },
{ re: /\s·\s*sheet$/i, group: "character" },
{ re: /\s·\s*environment$/i, group: "location" },
{ re: /\s·\s*item$/i, group: "item" },
{ re: /\s·\s*upload$/i, group: "upload" },
{ re: /\s·\s*blockout(\s+clip)?$/i, group: "shot" },
{ re: /^clip\s*#/i, group: "clip" },
];
export function assetGroup(opt: AssetOption): AssetGroup {
if (opt.group) return opt.group;
for (const { re, group } of GROUP_FROM_SUFFIX) {
if (re.test(opt.label)) return group;
}
return 'upload';
if (opt.group) return opt.group;
for (const { re, group } of GROUP_FROM_SUFFIX) {
if (re.test(opt.label)) return group;
}
return "upload";
}
/** Strip the " · sheet" style suffix so the tab already names the type. */
export function assetDisplayName(opt: AssetOption): string {
return opt.label.replace(/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i, '').trim() || opt.label;
return (
opt.label
.replace(
/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i,
"",
)
.trim() || opt.label
);
}
export const ASSET_GROUP_TABS: Array<{ id: AssetGroup; label: string }> = [
{ id: 'character', label: 'Characters' },
{ id: 'location', label: 'Environments' },
{ id: 'item', label: 'Misc. Items' },
{ id: 'shot', label: 'From Build Scene' },
{ id: 'clip', label: 'Clips' },
{ id: 'upload', label: 'Uploads' },
{ id: "character", label: "Characters" },
{ id: "location", label: "Environments" },
{ id: "item", label: "Misc. Items" },
{ id: "shot", label: "From Build Scene" },
{ id: "clip", label: "Clips" },
{ id: "upload", label: "Uploads" },
];
export function tabsForMediaKind(kind: string | undefined): Array<{ id: AssetGroup; label: string }> {
const ids: AssetGroup[] =
kind === 'video'
? ['clip', 'upload']
: kind === 'audio'
? ['upload']
: ['character', 'location', 'item', 'shot', 'upload'];
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
export function tabsForMediaKind(
kind: string | undefined,
): Array<{ id: AssetGroup; label: string }> {
const ids: AssetGroup[] =
kind === "video"
? ["clip", "upload"]
: kind === "audio"
? ["upload"]
: ["character", "location", "item", "shot", "upload"];
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
}
@@ -47,14 +47,12 @@
</header>
<div class="media">
{#if videoSrc}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<button
type="button"
class="media-btn"
onclick={openPreview}
title={t('canvas.openPreview')}
>
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="media-el"
src={videoSrc}
@@ -64,7 +62,6 @@
></video>
</button>
{:else if mediaSrc}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<button
type="button"
class="media-btn"
@@ -76,7 +76,6 @@
<SafeMedia class="media-el" src={posterSrc} alt={data.title} kind="image" controls={false} />
{:else if videoSrc}
<!-- No poster available: #t=0.1 first-frame thumbnail. -->
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="media-el"
src={videoSrc}
+102 -96
View File
@@ -12,24 +12,25 @@
* control → compact pills (resolution, duration, seed)
* advanced → hidden inside an "Advanced" popover
*/
import type { ComfyDynamicInput } from './types';
import { normalizeInputRole } from './parser';
import type { ComfyDynamicInput } from "./types";
import { normalizeInputRole } from "./parser";
export type InputZone = 'composer' | 'media' | 'control' | 'advanced' | 'hidden';
export type InputZone =
"composer" | "media" | "control" | "advanced" | "hidden";
export type OmniWidget =
| 'promptTextarea'
| 'negativeTextarea'
| 'uploadTile'
| 'assetTile'
| 'resolutionPill'
| 'numberPill'
| 'textPill';
| "promptTextarea"
| "negativeTextarea"
| "uploadTile"
| "assetTile"
| "resolutionPill"
| "numberPill"
| "textPill";
export interface ClassifiedInput {
input: ComfyDynamicInput;
zone: InputZone;
widget: OmniWidget;
input: ComfyDynamicInput;
zone: InputZone;
widget: OmniWidget;
}
/**
@@ -39,49 +40,49 @@ export interface ClassifiedInput {
* zone. Unknown roles fall to `advanced` so no field is ever hidden or lost.
*/
export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
const role = normalizeInputRole(inp.role);
const role = normalizeInputRole(inp.role);
// --- Composer ---
if (role === 'prompt' || role === 'positive') {
return { input: inp, zone: 'composer', widget: 'promptTextarea' };
}
if (role === 'negative') {
return { input: inp, zone: 'composer', widget: 'negativeTextarea' };
}
// --- Composer ---
if (role === "prompt" || role === "positive") {
return { input: inp, zone: "composer", widget: "promptTextarea" };
}
if (role === "negative") {
return { input: inp, zone: "composer", widget: "negativeTextarea" };
}
// --- Media tray ---
if (
role === 'character' ||
role === 'location' ||
role === 'image' ||
role === 'video' ||
role === 'audio'
) {
const isMediaKind =
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'audio' ||
inp.kind === 'video';
return {
input: inp,
zone: 'media',
widget: isMediaKind ? 'uploadTile' : 'assetTile',
};
}
// --- Media tray ---
if (
role === "character" ||
role === "location" ||
role === "image" ||
role === "video" ||
role === "audio"
) {
const isMediaKind =
inp.kind === "image" ||
inp.kind === "image_url" ||
inp.kind === "audio" ||
inp.kind === "video";
return {
input: inp,
zone: "media",
widget: isMediaKind ? "uploadTile" : "assetTile",
};
}
// --- Control bar ---
if (role === 'width' || role === 'height') {
return { input: inp, zone: 'control', widget: 'resolutionPill' };
}
if (role === 'duration' || role === 'seed') {
return { input: inp, zone: 'control', widget: 'numberPill' };
}
// --- Control bar ---
if (role === "width" || role === "height") {
return { input: inp, zone: "control", widget: "resolutionPill" };
}
if (role === "duration" || role === "seed") {
return { input: inp, zone: "control", widget: "numberPill" };
}
// --- Advanced popover ---
if (inp.kind === 'number') {
return { input: inp, zone: 'advanced', widget: 'numberPill' };
}
return { input: inp, zone: 'advanced', widget: 'textPill' };
// --- Advanced popover ---
if (inp.kind === "number") {
return { input: inp, zone: "advanced", widget: "numberPill" };
}
return { input: inp, zone: "advanced", widget: "textPill" };
}
/**
@@ -92,57 +93,62 @@ export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
* nodeIds on change.
*/
export function classifyAll(inputs: ComfyDynamicInput[]): {
prompt: ClassifiedInput | null;
negative: ClassifiedInput | null;
media: ClassifiedInput[];
control: ClassifiedInput[];
advanced: ClassifiedInput[];
/** Non-null when both width AND height are present → merge into one pill. */
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
prompt: ClassifiedInput | null;
negative: ClassifiedInput | null;
media: ClassifiedInput[];
control: ClassifiedInput[];
advanced: ClassifiedInput[];
/** Non-null when both width AND height are present → merge into one pill. */
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
} {
const classified = inputs.map(classifyInput);
const classified = inputs.map(classifyInput);
const prompt = classified.find((c) => c.widget === 'promptTextarea') ?? null;
const negative = classified.find((c) => c.widget === 'negativeTextarea') ?? null;
const media = classified.filter((c) => c.zone === 'media');
const advanced = classified.filter((c) => c.zone === 'advanced');
const prompt = classified.find((c) => c.widget === "promptTextarea") ?? null;
const negative =
classified.find((c) => c.widget === "negativeTextarea") ?? null;
const media = classified.filter((c) => c.zone === "media");
const advanced = classified.filter((c) => c.zone === "advanced");
// Separate resolution from other control inputs
const controlNonRes = classified.filter(
(c) => c.zone === 'control' && c.widget !== 'resolutionPill',
);
const resInputs = classified.filter((c) => c.widget === 'resolutionPill');
// Separate resolution from other control inputs
const controlNonRes = classified.filter(
(c) => c.zone === "control" && c.widget !== "resolutionPill",
);
const resInputs = classified.filter((c) => c.widget === "resolutionPill");
// Detect width + height pair
const wInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'width');
const hInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'height');
const resolutionPair =
wInput && hInput ? { width: wInput, height: hInput } : null;
// Detect width + height pair
const wInput = resInputs.find(
(c) => normalizeInputRole(c.input.role) === "width",
);
const hInput = resInputs.find(
(c) => normalizeInputRole(c.input.role) === "height",
);
const resolutionPair =
wInput && hInput ? { width: wInput, height: hInput } : null;
// If only one of width/height exists, treat it as a standalone control number pill
const standaloneRes = resolutionPair ? [] : resInputs;
// If only one of width/height exists, treat it as a standalone control number pill
const standaloneRes = resolutionPair ? [] : resInputs;
return {
prompt,
negative,
media,
control: [...controlNonRes, ...standaloneRes],
advanced,
resolutionPair,
};
return {
prompt,
negative,
media,
control: [...controlNonRes, ...standaloneRes],
advanced,
resolutionPair,
};
}
// ── Resolution presets ────────────────────────────────────────────────────────
export interface ResolutionPreset {
label: string;
width: number;
height: number;
label: string;
width: number;
height: number;
}
export const RESOLUTION_PRESETS: ResolutionPreset[] = [
{ label: '720p', width: 1280, height: 720 },
{ label: '1080p', width: 1920, height: 1080 },
{ label: "720p", width: 1280, height: 720 },
{ label: "1080p", width: 1920, height: 1080 },
];
/**
@@ -150,12 +156,12 @@ export const RESOLUTION_PRESETS: ResolutionPreset[] = [
* (or return null for custom / unset values).
*/
export function resolutionLabel(
width: number | string | undefined,
height: number | string | undefined,
width: number | string | undefined,
height: number | string | undefined,
): string | null {
const w = Number(width);
const h = Number(height);
if (!w || !h) return null;
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
return match?.label ?? `${w}×${h}`;
const w = Number(width);
const h = Number(height);
if (!w || !h) return null;
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
return match?.label ?? `${w}×${h}`;
}
+163 -113
View File
@@ -1,142 +1,192 @@
import type { ComfyDynamicInput, ComfyDynamicOutput, WorkflowNode } from './types';
import type {
ComfyDynamicInput,
ComfyDynamicOutput,
WorkflowNode,
} from "./types";
const TEXT_AREA = new Set([
'CLIPTextEncode',
'Note',
'ShowText',
'ImpactWildcardProcessor',
'PrimitiveStringMultiline',
'PrimitiveString',
"CLIPTextEncode",
"Note",
"ShowText",
"ImpactWildcardProcessor",
"PrimitiveStringMultiline",
"PrimitiveString",
]);
const NUMBER = new Set([
"INT",
"FLOAT",
"PrimitiveInt",
"PrimitiveFloat",
"KSampler",
"KSamplerAdvanced",
]);
const IMAGE = new Set([
"LoadImage",
"LoadImageMask",
"ImageLoader",
"ETN_LoadImageBase64",
]);
const IMAGE_URL = new Set(["Load Image From Url (mtb)"]);
const AUDIO = new Set(["LoadAudio", "VHS_LoadAudio"]);
const VIDEO = new Set(["LoadVideo", "VHS_LoadVideo", "VHS_LoadVideoPath"]);
const VIDEO_OUT = new Set([
"VHS_VideoCombine",
"SaveVideo",
"VideoOutput",
"AnimateDiffCombine",
]);
const IMAGE_OUT = new Set([
"SaveImage",
"PreviewImage",
"SaveImageWebsocket",
"ETN_SendImageWebSocket",
]);
const NUMBER = new Set(['INT', 'FLOAT', 'PrimitiveInt', 'PrimitiveFloat', 'KSampler', 'KSamplerAdvanced']);
const IMAGE = new Set(['LoadImage', 'LoadImageMask', 'ImageLoader', 'ETN_LoadImageBase64']);
const IMAGE_URL = new Set(['Load Image From Url (mtb)']);
const AUDIO = new Set(['LoadAudio', 'VHS_LoadAudio']);
const VIDEO = new Set(['LoadVideo', 'VHS_LoadVideo', 'VHS_LoadVideoPath']);
const VIDEO_OUT = new Set(['VHS_VideoCombine', 'SaveVideo', 'VideoOutput', 'AnimateDiffCombine']);
const IMAGE_OUT = new Set(['SaveImage', 'PreviewImage', 'SaveImageWebsocket', 'ETN_SendImageWebSocket']);
/** Matches (Input), (Input:prompt), (Output:image), … */
const TITLE_TAG_RE = /\((Input|Output)(?::([a-zA-Z0-9_-]+))?\)/i;
const INPUT_ROLE_ALIASES: Record<string, string[]> = {
prompt: ['prompt', 'positive'],
negative: ['negative', 'neg'],
width: ['width', 'w'],
height: ['height', 'h'],
character: ['character', 'char', 'portrait', 'sheet', 'face', 'ref'],
location: ['location', 'loc', 'environment', 'env', 'background', 'scene'],
image: ['image', 'img'],
video: ['video', 'vid'],
audio: ['audio', 'sound', 'sfx'],
seed: ['seed'],
duration: ['duration', 'dur', 'length', 'seconds'],
prompt: ["prompt", "positive"],
negative: ["negative", "neg"],
width: ["width", "w"],
height: ["height", "h"],
character: ["character", "char", "portrait", "sheet", "face", "ref"],
location: ["location", "loc", "environment", "env", "background", "scene"],
image: ["image", "img"],
video: ["video", "vid"],
audio: ["audio", "sound", "sfx"],
seed: ["seed"],
duration: ["duration", "dur", "length", "seconds"],
};
const OUTPUT_ROLE_ALIASES: Record<string, string[]> = {
image: ['image', 'img'],
video: ['video', 'vid'],
image: ["image", "img"],
video: ["video", "vid"],
};
export function parseTitleTag(title: string): {
kind: 'input' | 'output' | null;
role: string | null;
label: string;
kind: "input" | "output" | null;
role: string | null;
label: string;
} {
if (!title) return { kind: null, role: null, label: '' };
const match = TITLE_TAG_RE.exec(title);
if (!match) return { kind: null, role: null, label: title.trim() };
const kind = match[1].toLowerCase() as 'input' | 'output';
const role = match[2] ? match[2].toLowerCase() : null;
const stripped = title.replace(TITLE_TAG_RE, '').trim();
const label = stripped || role || kind;
return { kind, role, label };
if (!title) return { kind: null, role: null, label: "" };
const match = TITLE_TAG_RE.exec(title);
if (!match) return { kind: null, role: null, label: title.trim() };
const kind = match[1].toLowerCase() as "input" | "output";
const role = match[2] ? match[2].toLowerCase() : null;
const stripped = title.replace(TITLE_TAG_RE, "").trim();
const label = stripped || role || kind;
return { kind, role, label };
}
export function normalizeInputRole(role: string | null | undefined): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
export function normalizeInputRole(
role: string | null | undefined,
): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
}
export function normalizeOutputRole(role: string | null | undefined): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
export function normalizeOutputRole(
role: string | null | undefined,
): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
}
function classToInputKind(classType: string): ComfyDynamicInput['kind'] {
if (IMAGE.has(classType)) return 'image';
if (IMAGE_URL.has(classType)) return 'image_url';
if (AUDIO.has(classType)) return 'audio';
if (VIDEO.has(classType)) return 'video';
if (NUMBER.has(classType)) return 'number';
if (TEXT_AREA.has(classType)) return 'textarea';
const lower = classType.toLowerCase();
if (lower.includes('video')) return 'video';
if (lower.includes('audio')) return 'audio';
if (lower.includes('image') || lower.includes('load')) return 'image';
if (lower.includes('int') || lower.includes('float') || lower.includes('seed')) return 'number';
if (lower.includes('text') || lower.includes('clip') || lower.includes('prompt')) return 'textarea';
return 'text';
function classToInputKind(classType: string): ComfyDynamicInput["kind"] {
if (IMAGE.has(classType)) return "image";
if (IMAGE_URL.has(classType)) return "image_url";
if (AUDIO.has(classType)) return "audio";
if (VIDEO.has(classType)) return "video";
if (NUMBER.has(classType)) return "number";
if (TEXT_AREA.has(classType)) return "textarea";
const lower = classType.toLowerCase();
if (lower.includes("video")) return "video";
if (lower.includes("audio")) return "audio";
if (lower.includes("image") || lower.includes("load")) return "image";
if (
lower.includes("int") ||
lower.includes("float") ||
lower.includes("seed")
)
return "number";
if (
lower.includes("text") ||
lower.includes("clip") ||
lower.includes("prompt")
)
return "textarea";
return "text";
}
function extractDefault(node: WorkflowNode): string | number | undefined {
const { inputs, class_type } = node;
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type)) return undefined;
if (typeof inputs.text === 'string') return inputs.text;
if (typeof inputs.value === 'string' || typeof inputs.value === 'number') return inputs.value;
if (typeof inputs.int === 'number') return inputs.int;
if (typeof inputs.float === 'number') return inputs.float;
return undefined;
const { inputs, class_type } = node;
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type))
return undefined;
if (typeof inputs.text === "string") return inputs.text;
if (typeof inputs.value === "string" || typeof inputs.value === "number")
return inputs.value;
if (typeof inputs.int === "number") return inputs.int;
if (typeof inputs.float === "number") return inputs.float;
return undefined;
}
export function parseDynamicInputs(workflow: Record<string, WorkflowNode>): ComfyDynamicInput[] {
const results: ComfyDynamicInput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? '';
const { kind, role, label } = parseTitleTag(title);
if (kind !== 'input') continue;
results.push({
nodeId,
label: label || node.class_type,
role: normalizeInputRole(role),
kind: classToInputKind(node.class_type),
defaultValue: extractDefault(node),
required: true,
});
}
return results;
export function parseDynamicInputs(
workflow: Record<string, WorkflowNode>,
): ComfyDynamicInput[] {
const results: ComfyDynamicInput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? "";
const { kind, role, label } = parseTitleTag(title);
if (kind !== "input") continue;
results.push({
nodeId,
label: label || node.class_type,
role: normalizeInputRole(role),
kind: classToInputKind(node.class_type),
defaultValue: extractDefault(node),
required: true,
});
}
return results;
}
export function parseDynamicOutputs(workflow: Record<string, WorkflowNode>): ComfyDynamicOutput[] {
const results: ComfyDynamicOutput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? '';
const { kind, role, label } = parseTitleTag(title);
if (kind !== 'output') continue;
const canon = normalizeOutputRole(role);
let outKind: ComfyDynamicOutput['kind'] = 'other';
if (canon === 'video' || VIDEO_OUT.has(node.class_type) || node.class_type.toLowerCase().includes('video'))
outKind = 'video';
else if (
canon === 'image' ||
IMAGE_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes('image')
)
outKind = 'image';
results.push({
nodeId,
label: label || node.class_type,
role: canon,
kind: outKind,
});
}
return results;
export function parseDynamicOutputs(
workflow: Record<string, WorkflowNode>,
): ComfyDynamicOutput[] {
const results: ComfyDynamicOutput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? "";
const { kind, role, label } = parseTitleTag(title);
if (kind !== "output") continue;
const canon = normalizeOutputRole(role);
let outKind: ComfyDynamicOutput["kind"] = "other";
if (
canon === "video" ||
VIDEO_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes("video")
)
outKind = "video";
else if (
canon === "image" ||
IMAGE_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes("image")
)
outKind = "image";
results.push({
nodeId,
label: label || node.class_type,
role: canon,
kind: outKind,
});
}
return results;
}
+36 -32
View File
@@ -1,51 +1,55 @@
import type { ComfyDynamicInput } from './types';
import { normalizeInputRole } from './parser';
import type { ComfyDynamicInput } from "./types";
import { normalizeInputRole } from "./parser";
const PROMPT_ROLES = new Set(['prompt', 'positive']);
const PROMPT_ROLES = new Set(["prompt", "positive"]);
function roleOf(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
return normalizeInputRole(inp.role ?? null);
}
/** Positive prompt input — prefers (Input:prompt), legacy label fallback. */
export function isPromptLikeInput(inp: ComfyDynamicInput): boolean {
const role = roleOf(inp);
if (role && PROMPT_ROLES.has(role)) return true;
if (role) return false; // other roles are never "the" prompt
if (inp.kind !== 'text' && inp.kind !== 'textarea') return false;
const label = (inp.label || '').toLowerCase();
if (label.includes('negative')) return false;
return (
label.includes('prompt') ||
label.includes('positive') ||
label.includes('text') ||
inp.kind === 'textarea'
);
const role = roleOf(inp);
if (role && PROMPT_ROLES.has(role)) return true;
if (role) return false; // other roles are never "the" prompt
if (inp.kind !== "text" && inp.kind !== "textarea") return false;
const label = (inp.label || "").toLowerCase();
if (label.includes("negative")) return false;
return (
label.includes("prompt") ||
label.includes("positive") ||
label.includes("text") ||
inp.kind === "textarea"
);
}
/** Drop prompt-role fields from shared workflow forms (entity Image prompt owns those). */
export function withoutPromptInputs(inputs: ComfyDynamicInput[] | undefined | null): ComfyDynamicInput[] {
if (!inputs?.length) return [];
return inputs.filter((inp) => !isPromptLikeInput(inp));
export function withoutPromptInputs(
inputs: ComfyDynamicInput[] | undefined | null,
): ComfyDynamicInput[] {
if (!inputs?.length) return [];
return inputs.filter((inp) => !isPromptLikeInput(inp));
}
/** True if this workflow schema has a prompt-role (or legacy prompt-like) (Input). */
export function workflowHasPromptInput(inputs: ComfyDynamicInput[] | undefined | null): boolean {
if (!inputs?.length) return false;
return inputs.some(isPromptLikeInput);
export function workflowHasPromptInput(
inputs: ComfyDynamicInput[] | undefined | null,
): boolean {
if (!inputs?.length) return false;
return inputs.some(isPromptLikeInput);
}
/** Strip blank strings so empty fields cannot wipe smart-fill. */
export function compactInputValues(
values: Record<string, unknown> | undefined | null,
values: Record<string, unknown> | undefined | null,
): Record<string, unknown> {
if (!values) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === 'string' && !v.trim()) continue;
if (Array.isArray(v) && !v.length) continue;
out[k] = v;
}
return out;
if (!values) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === "string" && !v.trim()) continue;
if (Array.isArray(v) && !v.length) continue;
out[k] = v;
}
return out;
}
+26 -23
View File
@@ -3,39 +3,42 @@
* ratios and rounding, so the size shown is the size ComfyUI renders.
*/
const RATIOS: Record<string, [number, number]> = {
'1:1 (Square)': [1, 1],
'2:3 (Portrait Photo)': [2, 3],
'3:2 (Photo)': [3, 2],
'3:4 (Portrait Standard)': [3, 4],
'4:3 (Standard)': [4, 3],
'9:16 (Portrait Widescreen)': [9, 16],
'16:9 (Widescreen)': [16, 9],
'21:9 (Ultrawide)': [21, 9],
"1:1 (Square)": [1, 1],
"2:3 (Portrait Photo)": [2, 3],
"3:2 (Photo)": [3, 2],
"3:4 (Portrait Standard)": [3, 4],
"4:3 (Standard)": [4, 3],
"9:16 (Portrait Widescreen)": [9, 16],
"16:9 (Widescreen)": [16, 9],
"21:9 (Ultrawide)": [21, 9],
};
/** Python's round(): halves go to the even neighbour. */
function roundHalfEven(x: number): number {
const f = Math.floor(x);
const diff = x - f;
if (diff > 0.5) return f + 1;
if (diff < 0.5) return f;
return f % 2 === 0 ? f : f + 1;
const f = Math.floor(x);
const diff = x - f;
if (diff > 0.5) return f + 1;
if (diff < 0.5) return f;
return f % 2 === 0 ? f : f + 1;
}
export function resolutionSelectorSize(
aspectRatio: string,
megapixels: number,
multiple: number,
aspectRatio: string,
megapixels: number,
multiple: number,
): { width: number; height: number } | null {
const ratio = RATIOS[aspectRatio];
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
const [w, h] = ratio;
const m = Math.max(1, Math.floor(multiple) || 8);
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
return { width: roundHalfEven((w * scale) / m) * m, height: roundHalfEven((h * scale) / m) * m };
const ratio = RATIOS[aspectRatio];
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
const [w, h] = ratio;
const m = Math.max(1, Math.floor(multiple) || 8);
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
return {
width: roundHalfEven((w * scale) / m) * m,
height: roundHalfEven((h * scale) / m) * m,
};
}
/** "16:9 (Widescreen)" → "16:9". */
export function ratioShort(option: string): string {
return option.split(' ')[0] ?? option;
return option.split(" ")[0] ?? option;
}
+288 -278
View File
@@ -1,178 +1,184 @@
export type ComfyInputKind =
| 'text'
| 'textarea'
| 'number'
| 'select'
| 'image'
| 'image_url'
| 'audio'
| 'video'
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| 'image_list'
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
| 'video_list'
| 'audio_list';
export type ComfyOutputKind = 'image' | 'video' | 'other';
| "text"
| "textarea"
| "number"
| "select"
| "image"
| "image_url"
| "audio"
| "video"
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| "image_list"
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
| "video_list"
| "audio_list";
export type ComfyOutputKind = "image" | "video" | "other";
/** Structural finding on an imported workflow; `error` blocks generation. */
export interface WorkflowWarning {
level: 'error' | 'warning';
code: string;
node_id: string;
message: string;
level: "error" | "warning";
code: string;
node_id: string;
message: string;
}
export interface ComfyDynamicInput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
role?: string | null;
kind: ComfyInputKind;
defaultValue?: string | number;
required: boolean;
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
auto?: boolean;
/** ComfyUI widget limits (number kind). */
min?: number;
max?: number;
step?: number;
integer?: boolean;
/** Allowed values (select kind). */
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
media_hint?: string;
nodeId: string;
label: string;
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
role?: string | null;
kind: ComfyInputKind;
defaultValue?: string | number;
required: boolean;
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
auto?: boolean;
/** ComfyUI widget limits (number kind). */
min?: number;
max?: number;
step?: number;
integer?: boolean;
/** Allowed values (select kind). */
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
media_hint?: string;
}
export interface ComfyDynamicOutput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image). */
role?: string | null;
/** Null while an unknown custom node awaits the user's media-type pick. */
kind: ComfyOutputKind | null;
/** Untagged detection: node class + which rule typed it. */
class_type?: string;
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
/** Form default for unknown candidates — never auto-applied. */
suggested_kind?: 'image' | 'video' | null;
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image). */
role?: string | null;
/** Null while an unknown custom node awaits the user's media-type pick. */
kind: ComfyOutputKind | null;
/** Untagged detection: node class + which rule typed it. */
class_type?: string;
evidence?: "registry" | "class_name_match" | "unknown_node";
/** Form default for unknown candidates — never auto-applied. */
suggested_kind?: "image" | "video" | null;
}
/** One entry of a workflow's confirmed output mapping (HEL-638). */
export interface WorkflowOutputMapEntry {
nodeId: string;
kind: 'image' | 'video';
label?: string;
class_type?: string;
nodeId: string;
kind: "image" | "video";
label?: string;
class_type?: string;
}
/** The five workflow usages the studio distinguishes (HEL-636). */
export type WorkflowUsage =
| 'text2image'
| 'image2image'
| 'text2video'
| 'image2video'
| 'ref2video';
"text2image" | "image2image" | "text2video" | "image2video" | "ref2video";
export const WORKFLOW_USAGES: WorkflowUsage[] = [
'text2image',
'image2image',
'text2video',
'image2video',
'ref2video',
"text2image",
"image2image",
"text2video",
"image2video",
"ref2video",
];
/** Which usages each generate page serves. */
export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
image: ['text2image', 'image2image'],
video: ['text2video', 'image2video', 'ref2video'],
export const USAGE_BY_KIND: Record<"image" | "video", WorkflowUsage[]> = {
image: ["text2image", "image2image"],
video: ["text2video", "image2video", "ref2video"],
};
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
export interface PreflightFinding {
level: 'error' | 'warn' | 'info' | 'ok';
code: string;
node_id?: string;
node_title?: string | null;
message: string;
hint?: string | null;
level: "error" | "warn" | "info" | "ok";
code: string;
node_id?: string;
node_title?: string | null;
message: string;
hint?: string | null;
}
/** Result of checking a workflow against a target connection's /object_info. */
export interface WorkflowConnectionCheck {
endpoint_id: string | null;
endpoint_name: string | null;
base_url?: string | null;
/** False when any error-level finding exists (or the server was unreachable). */
ok: boolean;
/** False → 无法核查 (server down): honest, non-blocking. */
reachable: boolean;
findings: PreflightFinding[];
message?: string | null;
endpoint_id: string | null;
endpoint_name: string | null;
base_url?: string | null;
/** False when any error-level finding exists (or the server was unreachable). */
ok: boolean;
/** False → 无法核查 (server down): honest, non-blocking. */
reachable: boolean;
findings: PreflightFinding[];
message?: string | null;
}
/** Slim live record of a workflow's latest test run (HEL-643). */
export interface WorkflowLastTest {
id: number;
status: string;
phase: string | null;
error: string | null;
created_at: string;
completed_at: string | null;
output_count: number;
id: number;
status: string;
phase: string | null;
error: string | null;
created_at: string;
completed_at: string | null;
output_count: number;
}
/** Result of POST /api/workflows/analyze (import confirm step 1). */
export interface WorkflowAnalyzeResult {
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
warnings?: WorkflowWarning[];
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
needs_mapping: boolean;
usage_problem: string | null;
usage_evidence: {
outputs: ComfyDynamicOutput[];
image_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
video_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
summary: string[];
};
usable_usage: WorkflowUsage[];
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
connection_check?: WorkflowConnectionCheck;
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
warnings?: WorkflowWarning[];
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
needs_mapping: boolean;
usage_problem: string | null;
usage_evidence: {
outputs: ComfyDynamicOutput[];
image_slots: Array<{
nodeId: string;
label: string;
kind: string;
role: string | null;
}>;
video_slots: Array<{
nodeId: string;
label: string;
kind: string;
role: string | null;
}>;
summary: string[];
};
usable_usage: WorkflowUsage[];
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
connection_check?: WorkflowConnectionCheck;
}
export interface WorkflowNode {
class_type: string;
inputs: Record<string, unknown>;
_meta?: { title?: string };
class_type: string;
inputs: Record<string, unknown>;
_meta?: { title?: string };
}
export interface Workflow {
id: number;
name: string;
kind: 'image' | 'video';
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
usage_source?: 'auto' | 'user' | null;
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
output_map?: WorkflowOutputMapEntry[] | null;
output_map_source?: 'auto' | 'user' | null;
/** True when the graph has unknown output nodes needing designation. */
needs_mapping?: boolean;
/** Latest test-run record, queried live (HEL-643); null = never tested. */
last_test?: WorkflowLastTest | null;
id: number;
name: string;
kind: "image" | "video";
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
usage_source?: "auto" | "user" | null;
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
output_map?: WorkflowOutputMapEntry[] | null;
output_map_source?: "auto" | "user" | null;
/** True when the graph has unknown output nodes needing designation. */
needs_mapping?: boolean;
/** Latest test-run record, queried live (HEL-643); null = never tested. */
last_test?: WorkflowLastTest | null;
}
/**
@@ -180,195 +186,199 @@ export interface Workflow {
* point is browsing 150+ workflows without downloading workflow_json.
*/
export interface WorkflowMediaIO {
in: string[];
out: string[];
in: string[];
out: string[];
}
export interface WorkflowMeta {
id: number;
name: string;
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_source: 'auto' | 'user' | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: 'auto' | 'user' | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
id: number;
name: string;
kind: "image" | "video";
usage: WorkflowUsage | null;
usage_source: "auto" | "user" | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: "auto" | "user" | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
}
/** One UI-format file submitted to the batch convert/import pipeline. */
export interface WorkflowUIFileInput {
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
}
export interface WorkflowConvertIssue {
node_id: string;
code: string;
level: 'error' | 'warning';
message: string;
node_id: string;
code: string;
level: "error" | "warning";
message: string;
}
export interface WorkflowConvertSuggested {
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
kind: "image" | "video";
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
}
export interface WorkflowPreflightFinding {
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
}
export interface WorkflowConvertFileReport {
name: string;
source_path: string | null;
status: 'ok' | 'warn' | 'fail';
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
name: string;
source_path: string | null;
status: "ok" | "warn" | "fail";
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
}
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
export interface WorkflowConvertReport {
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
}
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
result?: 'imported' | 'skipped' | 'failed';
id?: number;
existing_id?: number;
result?: "imported" | "skipped" | "failed";
id?: number;
existing_id?: number;
}
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
export interface WorkflowImportResult {
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
files: WorkflowImportFileReport[];
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: {
name: string;
source_path: string | null;
reason: string | null;
}[];
files: WorkflowImportFileReport[];
}
export interface Job {
id: number;
project_id: number;
scene_id: number | null;
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
clip_id: number | null;
kind: string;
workflow_id: number | null;
status: string;
payload: Record<string, unknown>;
output_paths: string[];
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
retry_count: number;
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
/** HEL-684: present when this take has been flowed back to its
* workbench source row (link job id on the source project). */
workbench_attach?: {
job_id: number;
stage: string;
target_id: string;
};
id: number;
project_id: number;
scene_id: number | null;
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
clip_id: number | null;
kind: string;
workflow_id: number | null;
status: string;
payload: Record<string, unknown>;
output_paths: string[];
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
retry_count: number;
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
/** HEL-684: present when this take has been flowed back to its
* workbench source row (link job id on the source project). */
workbench_attach?: {
job_id: number;
stage: string;
target_id: string;
};
}
export interface SceneVideoSettings {
input_values?: Record<string, string | number>;
/** 'auto' | 'upload' | target scene id as string. */
clip_source?: string;
form_workflow_id?: number;
prompt_draft?: string;
prompt_draft_meta?: { based_on?: string; saved_at?: string };
[key: string]: unknown;
input_values?: Record<string, string | number>;
/** 'auto' | 'upload' | target scene id as string. */
clip_source?: string;
form_workflow_id?: number;
prompt_draft?: string;
prompt_draft_meta?: { based_on?: string; saved_at?: string };
[key: string]: unknown;
}
export interface Scene {
id: number;
project_id: number;
order_index: number;
heading: string | null;
action: string | null;
dialog: string | null;
duration_sec: number | null;
workflow_id: number | null;
env_image_path: string | null;
location_id: number | null;
video_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
character_ids: number[];
characters: Array<{
id: number;
name: string;
role: string | null;
portrait_path: string | null;
sheet_path: string | null;
}>;
clips: Clip[];
id: number;
project_id: number;
order_index: number;
heading: string | null;
action: string | null;
dialog: string | null;
duration_sec: number | null;
workflow_id: number | null;
env_image_path: string | null;
location_id: number | null;
video_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
character_ids: number[];
characters: Array<{
id: number;
name: string;
role: string | null;
portrait_path: string | null;
sheet_path: string | null;
}>;
clips: Clip[];
}
/** Shot unit: one scene expands into 1+ clips; each renders one ~5-10s video. */
export interface Clip {
id: number;
scene_id: number;
project_id: number;
order_index: number;
description: string | null;
shot_size: string | null;
dialog_lines_covered: number[] | null;
duration_sec: number | null;
workflow_id: number | null;
clip_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
/** '#3.2' — scene 3, clip 2 (display label). */
label?: string;
id: number;
scene_id: number;
project_id: number;
order_index: number;
description: string | null;
shot_size: string | null;
dialog_lines_covered: number[] | null;
duration_sec: number | null;
workflow_id: number | null;
clip_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
/** '#3.2' — scene 3, clip 2 (display label). */
label?: string;
}
+74 -70
View File
@@ -8,92 +8,96 @@
* - upload progress per nodeId
* - metadata (name, kind) for uploaded files
*/
import { playgroundApi, type UploadKind } from '$lib/api';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { playgroundApi, type UploadKind } from "$lib/api";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
export interface UploadState {
/** nodeId → uploading filename (present while upload is in-flight) */
uploading: Record<string, string>;
/** path → { name, kind } for previously uploaded files */
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
/** nodeId → uploading filename (present while upload is in-flight) */
uploading: Record<string, string>;
/** path → { name, kind } for previously uploaded files */
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
}
export function createUploadManager() {
let uploading = $state<Record<string, string>>({});
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>({});
let uploading = $state<Record<string, string>>({});
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>(
{},
);
return {
get uploading() {
return uploading;
},
get uploadedMeta() {
return uploadedMeta;
},
return {
get uploading() {
return uploading;
},
get uploadedMeta() {
return uploadedMeta;
},
isUploading(nodeId: string): boolean {
return !!uploading[nodeId];
},
isUploading(nodeId: string): boolean {
return !!uploading[nodeId];
},
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
const meta = uploadedMeta[path];
if (meta) return meta;
const base = path.split(/[/\\]/).pop() ?? path;
const ext = base.slice(base.lastIndexOf('.')).toLowerCase();
if (['.mp4', '.webm', '.mov', '.mkv'].includes(ext))
return { name: base, kind: 'video' };
if (['.mp3', '.wav', '.flac', '.ogg', '.m4a'].includes(ext))
return { name: base, kind: 'audio' };
return { name: base, kind: 'image' };
},
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
const meta = uploadedMeta[path];
if (meta) return meta;
const base = path.split(/[/\\]/).pop() ?? path;
const ext = base.slice(base.lastIndexOf(".")).toLowerCase();
if ([".mp4", ".webm", ".mov", ".mkv"].includes(ext))
return { name: base, kind: "video" };
if ([".mp3", ".wav", ".flac", ".ogg", ".m4a"].includes(ext))
return { name: base, kind: "audio" };
return { name: base, kind: "image" };
},
/**
* Upload a file and return the server path.
* Manages uploading state and metadata internally.
* Throws on failure (caller handles toast if needed).
*/
async upload(nodeId: string, file: File): Promise<string> {
uploading = { ...uploading, [nodeId]: file.name };
try {
const res = await playgroundApi.upload(file);
uploadedMeta = {
...uploadedMeta,
[res.path]: { name: res.name, kind: res.kind },
};
return res.path;
} finally {
const next = { ...uploading };
delete next[nodeId];
uploading = next;
}
},
/**
* Upload a file and return the server path.
* Manages uploading state and metadata internally.
* Throws on failure (caller handles toast if needed).
*/
async upload(nodeId: string, file: File): Promise<string> {
uploading = { ...uploading, [nodeId]: file.name };
try {
const res = await playgroundApi.upload(file);
uploadedMeta = {
...uploadedMeta,
[res.path]: { name: res.name, kind: res.kind },
};
return res.path;
} finally {
const next = { ...uploading };
delete next[nodeId];
uploading = next;
}
},
/**
* Convenience: upload + toast on error, return path or null.
*/
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
try {
return await this.upload(nodeId, file);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('toast.uploadFailed'));
return null;
}
},
};
/**
* Convenience: upload + toast on error, return path or null.
*/
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
try {
return await this.upload(nodeId, file);
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("toast.uploadFailed"),
);
return null;
}
},
};
}
export type UploadManager = ReturnType<typeof createUploadManager>;
export function truncateMiddle(name: string, max = 32): string {
if (name.length <= max) return name;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
if (name.length <= max) return name;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
}
export function acceptForKind(kind: string): string {
if (kind === 'audio') return 'audio/*';
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
if (kind === 'document') return '.txt,.md,.docx';
return 'image/*';
if (kind === "audio") return "audio/*";
if (kind === "video") return "video/*,.mp4,.webm,.mov,.mkv";
if (kind === "document") return ".txt,.md,.docx";
return "image/*";
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import { t } from '$lib/i18n.svelte';
@@ -15,7 +16,7 @@ interface Props {
<header class="header">
<div class="header-left">
<a class="logo" href="/" title={t('shell.brand')}>
<a class="logo" href={resolve('/')} title={t('shell.brand')}>
{t('shell.brand')}
</a>
{#if crumb}
@@ -27,13 +28,13 @@ interface Props {
</div>
{/if}
<nav class="top-nav" aria-label={t('nav.primary')}>
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href="/">{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
<a class="nav-link" class:active={active === 'models'} href="/models">{t('nav.models')}</a>
<a class="nav-link" class:active={active === 'settings'} href="/settings">{t('nav.settings')}</a>
<a class="nav-link" class:active={active === 'canvas'} href={resolve('/canvas')}>{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href={resolve('/')}>{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href={resolve('/build-scene')}>{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href={resolve('/library')}>{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href={resolve('/agents')}>{t('nav.agents')}</a>
<a class="nav-link" class:active={active === 'models'} href={resolve('/models')}>{t('nav.models')}</a>
<a class="nav-link" class:active={active === 'settings'} href={resolve('/settings')}>{t('nav.settings')}</a>
</nav>
</div>
<div class="header-right">
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createMutation, createQuery } from '@tanstack/svelte-query';
import {
playgroundApi,
@@ -137,9 +138,6 @@
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
const stageLink = $derived(
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
);
</script>
<div class="attach">
@@ -147,7 +145,7 @@
<div class="attached" role="status">
<span class="attached-check" aria-hidden="true"><Icon name="check" size={14} /></span>
<span class="attached-text">{t('attach.addedTo')} <strong>{attached.title}</strong></span>
<a class="attached-link" href={stageLink}>{t('attach.viewInProject')}</a>
<a class="attached-link" href={resolve(`/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}`)}>{t('attach.viewInProject')}</a>
<button
class="attached-dismiss"
type="button"
@@ -173,7 +171,7 @@
<span class="field-label">{t('attach.projectField')}</span>
<select class="field-select" bind:value={projectId}>
<option value="">{t('attach.selectProject')}</option>
{#each projectList as p}
{#each projectList as p (p.id)}
<option value={p.id}>{p.title}</option>
{/each}
</select>
@@ -194,7 +192,7 @@
<span class="field-label">{t('attach.characterField')}</span>
<select class="field-select" bind:value={characterId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectCharacter')}</option>
{#each characters as c}
{#each characters as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
@@ -207,7 +205,7 @@
<span class="field-label">{t('attach.locationField')}</span>
<select class="field-select" bind:value={locationId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectLocation')}</option>
{#each locations as loc}
{#each locations as loc (loc.id)}
<option value={loc.id}>{loc.name}</option>
{/each}
</select>
@@ -232,7 +230,7 @@
<span class="field-label">{t('attach.sceneField')}</span>
<select class="field-select" bind:value={sceneId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectScene')}</option>
{#each scenes as s}
{#each scenes as s (s.id)}
<option value={s.id}>#{s.order_index} {s.heading || t('attach.scene')}</option>
{/each}
</select>
@@ -229,6 +229,7 @@
{#if inputs.length === 0}
<p class="muted">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- This fixed key comes only from the seven bundled translation dictionaries, without interpolation or remote overrides. Retain the existing HTML-capable translation contract. -->
{@html t('cdf.noInputs')}
</p>
{:else}
@@ -381,7 +382,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -422,7 +424,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -453,7 +456,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -129,6 +129,7 @@
class="tool"
href={src}
download={downloadName}
rel="external"
aria-label={t('lightbox.downloadMedia')}
title={t('lightbox.download')}
onclick={(e) => e.stopPropagation()}
@@ -208,7 +209,7 @@
{:else}
{t('lightbox.clickToZoom')} · {t('lightbox.closeHint')} ·
{/if}
<a href={src} target="_blank" rel="noopener" onclick={(e) => e.stopPropagation()}
<a href={src} target="_blank" rel="noopener external" onclick={(e) => e.stopPropagation()}
>{t('lightbox.openOriginal')}</a
>
</p>
@@ -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] (code)}<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>
@@ -18,7 +18,7 @@
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
import { normalizeInputRole } from '$lib/comfy/parser';
import type { AssetOption } from '$lib/assetPicker';
import { assetUrl } from '$lib/api';
import '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import PillSelect from './omni/PillSelect.svelte';
@@ -48,7 +48,7 @@
onchanged,
}: Props = $props();
const client = useQueryClient();
useQueryClient();
let endpointId = $state('');
let values = $state<Record<string, string | number>>({});
@@ -146,7 +146,7 @@
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
});
$effect(() => {
stageLog.length;
void stageLog.length;
if (logBox) logBox.scrollTop = logBox.scrollHeight;
});
@@ -447,7 +447,7 @@
href={assetUrl(path)}
download
target="_blank"
rel="noreferrer"
rel="noreferrer external"
title={t('wft.download')}
>
<SafeMedia
@@ -61,6 +61,7 @@
/** Later tool results (wait_for_jobs) often have the finished status + paths. */
const trailJobsById = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Each derivation rebuilds this tool-result lookup from messages; only the completed Map escapes, with no later mutation.
const map = new Map<number, ArtifactJob>();
for (const row of messages) {
if (row.role !== 'tool' || !row.tool_result || typeof row.tool_result !== 'object') continue;
@@ -76,6 +76,7 @@ let {
void draftNonce;
if (draftNonce === 0) return;
if (!editorEl) return;
// eslint-disable-next-line svelte/no-dom-manipulating -- Template owns the empty editor shell; its user-editable subtree is imperative. Restore draft text before caret placement/focus and serialization.
editorEl.textContent = draft;
placeCaretAtEnd(editorEl);
editorEl.focus();
@@ -161,6 +162,7 @@ let {
attachments: [...attachments],
};
if (payloadIsEmpty(payload)) return;
// eslint-disable-next-line svelte/no-dom-manipulating -- Template does not render editor children; after serializing text/chips, clear the user-owned subtree before onSend, preserving submit/reset behavior.
if (editorEl) editorEl.innerHTML = '';
attachments = [];
mentionOpen = false;
@@ -291,8 +293,10 @@ let {
chip.after(space);
placeCaretAfter(space);
} else {
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor owns its children: fallback appends a noneditable workflow chip whose dataset serialize reads; keep template shell and caret logic intact.
editorEl.appendChild(makeChip(wf));
const space = document.createTextNode('\u00a0');
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor-owned trailing space provides a text caret after the workflow chip; placeCaretAfter uses this exact node.
editorEl.appendChild(space);
placeCaretAfter(space);
}
@@ -327,8 +331,10 @@ let {
chip.after(space);
placeCaretAfter(space);
} else {
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor owns its children: fallback appends a noneditable skill chip whose dataset serialize reads; keep template shell and caret logic intact.
editorEl.appendChild(makeSkillChip(skill));
const space = document.createTextNode('\u00a0');
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor-owned trailing space provides a text caret after the skill chip; placeCaretAfter uses this exact node.
editorEl.appendChild(space);
placeCaretAfter(space);
}
@@ -28,6 +28,7 @@
const sandbox = $derived(sessions.filter((s) => s.project_id == null));
const grouped = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local grouping accumulator is consumed as an array in this derivation; sessions drive recomputation, not Map writes.
const map = new Map<number, { project: AgentSession['project']; items: AgentSession[] }>();
for (const s of sessions) {
if (s.project_id == null) continue;
@@ -1,16 +1,9 @@
<script lang="ts">
<script lang="ts" generics="Item extends { id?: number; name: string; kind?: string; description?: string | null }">
import { t } from '$lib/i18n.svelte';
interface MenuItem {
id?: number;
name: string;
kind?: string;
description?: string | null;
}
interface Props {
open: boolean;
items: MenuItem[];
items: Item[];
activeIndex: number;
/** Caret / `@` viewport box — menu opens above this (composer is bottom-docked). */
anchor: { top: number; bottom: number; left: number };
@@ -18,7 +11,7 @@
lockReason?: string | null;
/** Skill mode: rows show `/name` + a description subtitle, no kind chip. */
skillMode?: boolean;
onSelect: (item: any) => void;
onSelect: (item: Item) => void;
onHover: (index: number) => void;
}
@@ -62,6 +55,7 @@
if (!open || !menuEl) return;
document.body.appendChild(menuEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates menu contents; this effect moves the root to body and owns portal teardown on close/unmount. Remove that moved root to avoid residue.
menuEl?.remove();
};
});
@@ -7,68 +7,68 @@
* 不再使用彩色身份色;默认主循环智能体保持中性 --id-4。
*/
export const DEFAULT_AGENT_NAME = 'Calliope';
export const DEFAULT_AGENT_NAME = "Calliope";
// Fixed identity map (keyed by the raw agent_name the backend emits).
// Step numbers mirror the avatar five-set used across the app
// (models/agents/home project cards).
const IDENTITIES: Record<string, { label: string; color: string }> = {
planner: { label: 'Planner', color: 'var(--id-2)' },
'story-agent': { label: 'Story Agent', color: 'var(--id-3)' },
'script-agent': { label: 'Script Agent', color: 'var(--id-1)' },
'assets-agent': { label: 'Assets Agent', color: 'var(--id-4)' },
'video-agent': { label: 'Video Agent', color: 'var(--id-5)' },
'research-agent': { label: 'Research Agent', color: 'var(--id-4)' },
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; steps mirror the avatar five-set.
剧本创作: { label: '剧本创作', color: 'var(--id-1)' },
视觉设计: { label: '视觉设计', color: 'var(--id-2)' },
分镜设计: { label: '分镜设计', color: 'var(--id-3)' },
素材资产: { label: '素材资产', color: 'var(--id-4)' },
镜头脚本: { label: '镜头脚本', color: 'var(--id-5)' },
planner: { label: "Planner", color: "var(--id-2)" },
"story-agent": { label: "Story Agent", color: "var(--id-3)" },
"script-agent": { label: "Script Agent", color: "var(--id-1)" },
"assets-agent": { label: "Assets Agent", color: "var(--id-4)" },
"video-agent": { label: "Video Agent", color: "var(--id-5)" },
"research-agent": { label: "Research Agent", color: "var(--id-4)" },
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; steps mirror the avatar five-set.
剧本创作: { label: "剧本创作", color: "var(--id-1)" },
视觉设计: { label: "视觉设计", color: "var(--id-2)" },
分镜设计: { label: "分镜设计", color: "var(--id-3)" },
素材资产: { label: "素材资产", color: "var(--id-4)" },
镜头脚本: { label: "镜头脚本", color: "var(--id-5)" },
};
// Fallback palette for unknown names (stable hash → deterministic step),
// spanning the same five grayscale identity steps.
const FALLBACK_COLORS = [
'var(--id-1)',
'var(--id-2)',
'var(--id-3)',
'var(--id-4)',
'var(--id-5)',
"var(--id-1)",
"var(--id-2)",
"var(--id-3)",
"var(--id-4)",
"var(--id-5)",
];
function hashString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return h;
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return h;
}
/** Neutral step (--id-4) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = 'var(--id-4)';
export const DEFAULT_AGENT_COLOR = "var(--id-4)";
export function agentDisplayName(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
if (!key) return DEFAULT_AGENT_NAME;
return IDENTITIES[key]?.label ?? prettyName(name!);
const key = (name || "").trim().toLowerCase();
if (!key) return DEFAULT_AGENT_NAME;
return IDENTITIES[key]?.label ?? prettyName(name!);
}
export function agentColor(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
if (!key) return DEFAULT_AGENT_COLOR;
const identity = IDENTITIES[key];
if (identity) return identity.color;
return FALLBACK_COLORS[hashString(key) % FALLBACK_COLORS.length];
const key = (name || "").trim().toLowerCase();
if (!key) return DEFAULT_AGENT_COLOR;
const identity = IDENTITIES[key];
if (identity) return identity.color;
return FALLBACK_COLORS[hashString(key) % FALLBACK_COLORS.length];
}
function prettyName(raw: string): string {
// "story-agent" → "Story Agent", "assets-agent" → "Assets Agent"
const title = raw
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
return title || DEFAULT_AGENT_NAME;
// "story-agent" → "Story Agent", "assets-agent" → "Assets Agent"
const title = raw
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
return title || DEFAULT_AGENT_NAME;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { untrack, type Snippet } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
@@ -59,6 +60,9 @@
onCancel: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
canAttach?: boolean;
attachBusy?: boolean;
onAttach?: () => void;
}
let {
@@ -68,21 +72,20 @@
job,
wf,
nowMs,
open,
inactive,
canSendToSlot,
crossfade,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onCancel,
onSendToSlot,
onDelete,
canAttach = false,
attachBusy = false,
onAttach,
}: Props = $props();
let detailsOpen = $state(false);
let deleteOpen = $state(false);
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
@@ -90,6 +93,7 @@
$effect(() => {
void job?.id;
detailsOpen = false;
deleteOpen = false;
compare = false;
lightbox = false;
});
@@ -111,7 +115,6 @@
const output = $derived(
outputs[Math.min(outIndex, outputs.length - 1)] ?? null,
);
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const wfDeleted = $derived(!!job && job.workflow_id == null);
const prompt = $derived(job ? promptOf(job, wf) : '');
@@ -132,16 +135,27 @@
return live?.progress ?? 3;
});
let dims = $state('');
let naturalWidth = $state(1),
naturalHeight = $state(1);
let stageWidth = $state(0),
stageHeight = $state(0);
const fitted = $derived(
Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight),
);
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
naturalWidth = v.videoWidth;
naturalHeight = v.videoHeight;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
@@ -175,40 +189,29 @@
</script>
<div class="artboard" aria-label="生成结果">
<header class="artboard-toolbar">
<button
class="artboard-toolbar"
aria-label="作品详情与操作"
onclick={() => (detailsOpen = true)}
>
<div class="result-title">
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
</div>
<div class="canvas-actions">
{#if output && !video}<button
class="canvas-btn"
onclick={() => (lightbox = true)}
aria-label="放大查看">⤢</button
>
{#if compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => (compare = !compare)}
>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
{/if}
{#if output}<a
class="canvas-btn"
href={assetUrl(output)}
download
aria-label="下载生成结果">↓ 下载</a
>{/if}
<button class="canvas-btn" onclick={() => (detailsOpen = true)}
>详情与操作</button
>
</div>
</header>
</button>
<div class="stage" class:comparing={compare}>
{#if job && st}{#key mediaKey}
<div class="stage-media">
<div
class="stage-media"
bind:clientWidth={stageWidth}
bind:clientHeight={stageHeight}
>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div class="print a-photo">
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
@@ -217,6 +220,12 @@
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onkeydown={(e) => {
if (e.code === 'Space' && e.target === e.currentTarget) {
e.preventDefault();
togglePlayback();
}
}}
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
@@ -228,9 +237,21 @@
}}
onerror={() => output && markOutputMissing(output)}
></video>
{#if paused}<button
class="big-play"
aria-label="播放此视频"
onclick={() => {
videoEl?.focus();
togglePlayback();
}}>▶</button
>{/if}
</div>
{:else if output}
<div class="print a-photo">
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
@@ -279,7 +300,8 @@
role="group"
aria-label="本次生成的作品"
>
{#each outputs as p, i}<button
<!-- Output paths may repeat; the button number and outIndex refer to this position. -->
{#each outputs as p, i (i)}<button
aria-pressed={output === p}
onclick={() => (outIndex = i)}
aria-label={'查看第 ' + (i + 1) + ' 件作品'}>{i + 1}</button
@@ -287,19 +309,73 @@
</div>{/if}
</div>
<footer class="artboard-footer">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
<button
class="canvas-btn"
disabled={!output}
onclick={() =>
video
? videoEl?.requestFullscreen().catch(() => {})
: (lightbox = true)}
aria-label="放大查看"><Icon name="zoom-in" size={14} />放大</button
>
<a
class="canvas-btn"
href={output ? assetUrl(output) : undefined}
aria-disabled={!output}
download
rel="external"
aria-label="下载生成结果"><Icon name="download" size={14} />下载</a
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!job || !output}
onclick={() => job && onReuse(job)}
><Icon name="retry" size={14} />复用参数</button
>
<button
class="canvas-btn"
disabled={!output || video || !canSendToSlot}
title={canSendToSlot
? '放入当前工作流图片输入'
: '当前工作流没有图片输入'}
onclick={() => output && onSendToSlot(output)}
><Icon name="image" size={14} />作为参考图</button
>
<button
class="canvas-btn"
disabled
title="跨工作流送图尚未具备,请选择生视频工作流后从已有结果选取"
><Icon name="film" size={14} />送去生视频</button
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!canAttach || attachBusy}
title={canAttach
? '保存到来源项目'
: '通用选择项目与阶段将在后续批次提供'}
onclick={onAttach}><Icon name="folder" size={14} />存入项目</button
>
<a class="canvas-btn" href={resolve('/library')}
><Icon name="assets" size={14} />素材库</a
>
<button
class="canvas-btn delete-output"
aria-label="删除作品记录"
title="删除作品记录"
disabled={!job || job.status === 'pending' || job.status === 'running'}
onclick={() => (deleteOpen = true)}
><Icon name="close" size={14} /></button
>
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
@@ -309,19 +385,41 @@
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
</footer>
</div>
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
{#if job && st}
<div class="detail-actions">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
>
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if output && !video && compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => {
compare = !compare;
detailsOpen = false;
}}>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
<button
class="a-btn secondary sm"
onclick={() => {
@@ -348,7 +446,7 @@
{#if job.status !== 'pending' && job.status !== 'running'}<button
class="a-btn danger sm"
onclick={() => {
onDelete(job);
deleteOpen = true;
detailsOpen = false;
}}>移除记录</button
>{/if}
@@ -357,7 +455,8 @@
{fileGone ? t('jobst.missingDetail') : st.detail}
</p>{/if}
<div class="detail-meta">
{#each spec as s}<span>{s.label} · {s.value}</span
<!-- Display-only spec rows have no local state; preserve their existing positional reuse. -->
{#each spec as s, i (i)}<span>{s.label} · {s.value}</span
>{/each}{#if endpoint}<span>{endpoint}</span>{/if}
</div>
{#if inputs.length || prompt}
@@ -409,6 +508,23 @@
</dl>
{/if}
</Modal>
<Modal bind:open={deleteOpen} title="删除作品记录" size="md">
<p>
确认移除这条生成记录及生成文件?关联项目中的回流记录也会移除。确认后有 5
秒可撤销,提交后无法恢复。
</p>
{#snippet footer()}<button
class="a-btn secondary"
onclick={() => (deleteOpen = false)}>取消</button
>
<button
class="a-btn danger"
onclick={() => {
if (job) onDelete(job);
deleteOpen = false;
}}>确认移除</button
>{/snippet}
</Modal>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
@@ -417,13 +533,14 @@
<style>
.artboard {
position: relative;
display: grid;
grid-template-rows: 38px minmax(0, 1fr) 32px;
grid-template-rows: 0 minmax(0, 1fr) 40px;
height: 100%;
min-height: 0;
border: 1px solid #ffffff0d;
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
border: 0;
border-radius: 0;
background: transparent;
overflow: hidden;
}
.artboard-toolbar,
@@ -431,10 +548,24 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: 6px;
padding: 0 12px;
min-width: 0;
}
.artboard-toolbar {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
padding: 2px 6px;
max-width: calc(100% - 20px);
border: 0;
cursor: pointer;
text-align: left;
}
.result-title {
display: flex;
align-items: center;
@@ -451,25 +582,46 @@
text-overflow: ellipsis;
max-width: 220px;
}
.canvas-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.canvas-btn {
background: #ffffff04;
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
box-sizing: border-box;
background: var(--bg-2);
color: var(--text-2);
border: 1px solid #ffffff0a;
border-radius: 6px;
padding: 5px 8px;
font-size: 10px;
border: 1px solid var(--line);
border-radius: var(--desk-action-radius);
padding: 0 9px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.action-separator {
width: 1px;
height: 18px;
flex-shrink: 0;
margin: 0 4px;
background: var(--line-2);
}
.delete-output {
width: 26px;
padding: 0;
justify-content: center;
color: var(--err);
background: transparent;
border-color: transparent;
}
.canvas-btn:disabled,
.canvas-btn[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
.canvas-btn:hover {
color: var(--text);
background: #ffffff0a;
background: var(--bg-3);
}
.canvas-btn[aria-pressed='true'],
.play-button {
@@ -477,6 +629,7 @@
border-color: var(--accent-line);
}
.stage {
grid-row: 2;
min-height: 0;
min-width: 0;
position: relative;
@@ -485,7 +638,7 @@
.stage-media {
position: absolute;
inset: 0;
padding: 8px 20px;
padding: 0;
display: grid;
place-items: center;
min-height: 0;
@@ -503,6 +656,20 @@
background: none;
box-shadow: none;
}
.big-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%;
border: 1px solid var(--on-media-line);
color: var(--on-media);
background: var(--media-scrim);
font-size: 26px;
cursor: pointer;
}
.print img,
.print video {
width: 100%;
@@ -540,7 +707,7 @@
.comparison-layer {
position: absolute;
inset: 0;
background: #17171d;
background: var(--bg-0);
pointer-events: none;
}
.comparison-divider {
@@ -552,7 +719,10 @@
pointer-events: none;
}
.artboard-footer {
justify-content: flex-start;
grid-row: 3;
justify-content: center;
gap: 6px;
padding: 0;
font-size: 10px;
color: var(--text-3);
}
@@ -578,7 +748,7 @@
gap: 4px;
max-width: 90%;
overflow: auto;
background: #101015d9;
background: var(--media-scrim);
border: 1px solid var(--line);
padding: 4px;
border-radius: 8px;
@@ -1,13 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { isWorkflowRunnable } from '$lib/workflowState';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { workflows, apiErrorMessage } from '$lib/api';
import type { WorkflowMeta } from '$lib/comfy/types';
import { usageLabel } from '$lib/components/generate/schema';
import { spotlight } from '$lib/actions/spotlight';
import { toast } from '$lib/toast';
import Icon from '$lib/components/ui/Icon.svelte';
let {
kind,
initialUsage = 'all',
metas,
selectedId,
pendingCount,
@@ -15,9 +20,14 @@
onKind,
onDetails,
disabled = false,
historyCollapsed = false,
onToggleHistory,
}: {
disabled?: boolean;
historyCollapsed?: boolean;
onToggleHistory: () => void;
kind: 'image' | 'video';
initialUsage?: string;
metas: WorkflowMeta[];
selectedId: number | null;
pendingCount: number;
@@ -25,11 +35,47 @@
onKind: (kind: 'image' | 'video') => void;
onDetails: () => void;
} = $props();
let searchInput: HTMLInputElement;
let searchOrigin: HTMLElement | null = null;
function searchKeydown(e: KeyboardEvent) {
if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
if (
[
...document.querySelectorAll('.shell-pop, .modal, [role="dialog"]'),
].some((el) => el.getClientRects().length)
)
return;
if (e.key === 'Escape' && e.target === searchInput) {
e.preventDefault();
const origin = searchOrigin?.isConnected
? searchOrigin
: searchInput.closest('aside')?.querySelector('button');
if (origin instanceof HTMLElement) origin.focus();
else searchInput.blur();
return;
}
const el = e.target instanceof HTMLElement ? e.target : null;
if (
e.key !== '/' ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
el?.isContentEditable ||
el?.closest(
'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
) ||
!searchInput?.getClientRects().length
)
return;
e.preventDefault();
searchInput.focus();
}
const client = useQueryClient();
let search = $state(''),
usage = $state('all'),
usage = $state(untrack(() => initialUsage)),
family = $state('all'),
favOnly = $state(false),
runnableOnly = $state(true),
limit = $state(48),
pins = $state<number[]>([]);
onMount(() => {
@@ -51,10 +97,18 @@
metas
.filter(
(m) =>
(!runnableOnly || runnable(m)) &&
(!favOnly || m.favorite) &&
(usage === 'all' || m.usage === usage) &&
(family === 'all' || m.model_family === family) &&
[m.name, m.description, m.model_family, ...(m.tags ?? [])]
[
m.name,
m.description,
m.source_path,
m.usage ? usageLabel(m.usage) : null,
m.model_family,
...(m.tags ?? []),
]
.join(' ')
.toLowerCase()
.includes(search.trim().toLowerCase()),
@@ -63,6 +117,10 @@
(a, b) =>
Number(pins.includes(b.id)) - Number(pins.includes(a.id)) ||
Number(b.favorite) - Number(a.favorite) ||
(a.model_family ?? '本地工作流').localeCompare(
b.model_family ?? '本地工作流',
'zh',
) ||
a.name.localeCompare(b.name, 'zh'),
),
);
@@ -77,12 +135,23 @@
const index = filtered.findIndex((m) => m.id === selectedId);
if (index >= limit) limit = index + 24;
});
function runnable(m: WorkflowMeta) {
return isWorkflowRunnable(m);
}
function choose(m: WorkflowMeta) {
if (runnable(m)) onPick(m.id);
else void goto(resolve(`/workflows?manage=1&wf=${m.id}`));
}
function pin(id: number) {
pins = pins.includes(id) ? pins.filter((p) => p !== id) : [...pins, id];
localStorage.setItem(
'calliope.reference.workflowPins',
JSON.stringify(pins),
);
try {
localStorage.setItem(
'calliope.reference.workflowPins',
JSON.stringify(pins),
);
} catch {
/* Pins remain usable this visit. */
}
}
const favorite = createMutation({
mutationFn: (m: WorkflowMeta) =>
@@ -92,50 +161,89 @@
});
</script>
<svelte:window onkeydown={searchKeydown} />
<aside class="workflow-rail" aria-label="创作工作流列表">
<header>
<div><strong>创作台</strong><small>选择工作流,开始创作</small></div>
<strong>创作台</strong><button
class="history-toggle a-btn ghost sm"
onclick={onToggleHistory}
aria-label={historyCollapsed ? '展开历史栏' : '收起历史栏'}
aria-expanded={!historyCollapsed}>◫</button
>
</header>
<div class="rail-tools">
<div class="kind-tabs" role="tablist" aria-label="创作类型">
<button
role="tab"
{disabled}
aria-selected={kind === 'image'}
onclick={() => onKind('image')}>图片</button
><button
role="tab"
{disabled}
onclick={() => onKind('image')}>图片</button
>
<button
role="tab"
aria-selected={kind === 'video'}
{disabled}
onclick={() => onKind('video')}>视频</button
>
</div>
<input
type="search"
bind:value={search}
aria-label="搜索工作流"
placeholder="搜索工作流或用途…"
/>
<div class="filters">
<select aria-label="筛选工作流用途" bind:value={usage}
><option value="all">所有用途</option>{#each usages as u}<option
value={u!}>{usageLabel(u!)}</option
>{/each}</select
><select aria-label="筛选模型家族" bind:value={family}
><option value="all">全部模型</option>{#each families as f}<option
value={f!}>{f}</option
>{/each}</select
<div class="rail-search">
<Icon name="search" size={14} />
<input
type="search"
bind:this={searchInput}
onfocus={(e) => {
searchOrigin =
e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null;
}}
bind:value={search}
aria-label="搜索工作流"
placeholder="搜用途、模型或文件名"
/>
<kbd>/</kbd>
</div>
<div class="filters" role="group" aria-label="筛选工作流用途">
<button aria-pressed={usage === 'all'} onclick={() => (usage = 'all')}
>全部</button
>
{#each usages as u (u)}<button
aria-pressed={usage === u}
onclick={() => (usage = u!)}>{usageLabel(u!)}</button
>{/each}
<button aria-pressed={favOnly} onclick={() => (favOnly = !favOnly)}
>☆ 收藏</button
>
</div>
{#if families.length}<div
class="filters"
role="group"
aria-label="筛选模型家族"
>
<button aria-pressed={family === 'all'} onclick={() => (family = 'all')}
>全部模型</button
>
{#each families as f (f)}<button
aria-pressed={family === f}
onclick={() => (family = f!)}>{f}</button
>{/each}
</div>{/if}
<div class="list-meta">
<span title="目录接口不提供仓库文件总数"
>{filtered.length} 个 · 仓库文件数未知</span
>
<label class="runnable-filter"
><input type="checkbox" bind:checked={runnableOnly} />仅可运行</label
>
</div>
</div>
<div class="list-meta">
<span>{filtered.length} 个可用工作流</span><button
aria-pressed={favOnly}
onclick={() => (favOnly = !favOnly)}>{favOnly ? '★' : '☆'} 收藏</button
>
</div>
<div class="rail-list">
{#each filtered.slice(0, limit) as m (m.id)}<div
{#each filtered.slice(0, limit) as m, index (m.id)}
{#if index === 0 || pins.includes(m.id) !== pins.includes(filtered[index - 1].id) || (!pins.includes(m.id) && m.model_family !== filtered[index - 1].model_family)}<div
class="family-label"
>
{pins.includes(m.id) ? '置顶工作流' : m.model_family || '本地工作流'}
</div>{/if}
<div
class="workflow-row reference-option"
use:spotlight
class:selected={selectedId === m.id}
@@ -143,12 +251,15 @@
<button
class="workflow-pick"
{disabled}
onclick={() => onPick(m.id)}
onclick={() => choose(m)}
aria-pressed={selectedId === m.id}
title={m.name}
><i class:video={kind === 'video'}>{kind === 'image' ? '◈' : '▷'}</i
><i class:video={kind === 'video'}
>{m.model_family?.slice(0, 3) || (kind === 'image' ? '◈' : '▷')}</i
><span
><b>{m.name}</b><small
><b>{m.name}</b>{#if !runnable(m)}<small class="pending"
>待配置 · 映射 / 测试 →</small
>{/if}<small
>{m.description ||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
></span
@@ -178,108 +289,184 @@
>{/if}
</div>
<footer>
{#if pendingCount}<a href="/workflows?manage=1"
{#if pendingCount}<a href={resolve('/workflows?manage=1')}
>{pendingCount} 个工作流待配置 →</a
>{/if}<button class="manage" onclick={onDetails}
>{/if}<button class="manage" data-workflow-details onclick={onDetails}
><span>+</span>
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
<b>›</b></button
><a href="/models">云端模型与接口 ↗</a>
><a href={resolve('/models')}>云端模型与接口 ↗</a>
</footer>
</aside>
<style>
.workflow-rail {
width: 232px;
box-sizing: border-box;
width: var(--list);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: #121216;
background: var(--bg-1);
border-right: 1px solid var(--line);
}
.workflow-rail > header {
padding: 21px 18px 16px;
border-bottom: 1px solid var(--line);
padding: 12px 12px 0;
height: var(--desk-rail-title-height);
box-sizing: border-box;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
header strong {
font-size: 18px;
font-size: 14px;
font-weight: 600;
display: block;
}
header small {
display: block;
font-size: 11px;
color: var(--text-3);
margin-top: 6px;
}
.rail-tools {
padding: 14px 12px 7px;
padding: var(--desk-rail-gap) 12px;
border-bottom: 1px solid var(--line);
}
.kind-tabs {
display: flex;
gap: 3px;
background: #09090c;
border: 1px solid var(--line);
border-radius: 9px;
gap: 2px;
padding: 3px;
height: 36px;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: var(--desk-seg-radius);
background: var(--well);
}
.kind-tabs button {
flex: 1;
height: 28px;
padding: 0 12px;
font-size: 12.5px;
border: 0;
padding: 7px 3px;
background: none;
color: var(--text-3);
font-size: 12px;
border-radius: 6px;
border-radius: var(--desk-seg-selected-radius);
background: transparent;
color: var(--tx-2);
cursor: pointer;
}
.kind-tabs [aria-selected='true'] {
color: #062f35;
background: var(--accent);
font-weight: 600;
background: var(--bg-3);
box-shadow: var(--sh-1);
color: var(--tx-1);
}
.rail-tools input {
width: 100%;
margin-top: 10px;
font-size: 11px;
padding: 9px 10px;
background: #1d1b16;
border: 1px solid #f5b04228;
border-radius: 8px;
.kind-tabs button:disabled {
cursor: wait;
}
.rail-search {
display: flex;
align-items: center;
gap: 8px;
margin-top: var(--desk-rail-gap);
height: var(--desk-search-height);
box-sizing: border-box;
font-size: var(--desk-search-size);
padding: 0 10px;
background: var(--well);
border: 1px solid var(--line-2);
border-radius: var(--desk-search-radius);
color: var(--text);
}
.rail-search input {
min-width: 0;
flex: 1;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
outline: none;
}
.rail-search kbd {
font: var(--desk-search-key-font);
padding: var(--desk-search-key-padding);
border-radius: var(--desk-search-key-radius);
border: 1px solid var(--line-2);
color: var(--tx-3);
background: var(--bg-1);
}
.rail-search:focus-within {
border-color: var(--scene);
}
.filters {
display: flex;
gap: 5px;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.filters select {
width: 50%;
min-width: 0;
.filters button {
height: var(--desk-filter-height);
padding: 0 9px;
font-size: var(--desk-filter-size);
border-radius: var(--desk-filter-radius);
border: 1px solid var(--line);
background: var(--bg-3);
border-radius: 5px;
padding: 5px;
font-size: 10px;
color: var(--text-3);
background: var(--bg-2);
color: var(--tx-2);
cursor: pointer;
}
.filters button[aria-pressed='true'] {
color: var(--tx-1);
border-color: color-mix(in srgb, var(--scene) 60%, transparent);
background: color-mix(in srgb, var(--scene) 13%, var(--bg-2));
}
.runnable-filter {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
cursor: pointer;
}
.runnable-filter input {
margin: 0;
appearance: none;
width: var(--desk-switch-width);
height: var(--desk-switch-height);
border: 0;
border-radius: var(--r-pill);
background: var(--bg-4);
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.runnable-filter input::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: var(--desk-switch-knob);
height: var(--desk-switch-knob);
border-radius: 50%;
background: var(--tx-2);
transition: transform var(--t-base);
}
.runnable-filter input:checked {
background: color-mix(in srgb, var(--scene) 55%, var(--bg-4));
}
.runnable-filter input:checked::after {
transform: translateX(11px);
background: var(--on-media);
}
.pending {
color: var(--warn);
}
.list-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
font-size: 10px;
color: var(--text-3);
gap: 6px;
padding: 8px 2px 0;
font-size: var(--desk-filter-size);
color: var(--tx-3);
}
.list-meta button {
border: 0;
background: none;
color: var(--text-3);
font-size: 10px;
cursor: pointer;
}
.list-meta button[aria-pressed='true'] {
color: var(--warn);
.family-label {
font-size: 11px;
color: var(--tx-3);
padding: 8px;
}
.rail-list {
flex: 1;
@@ -297,8 +484,12 @@
margin: 2px 0;
}
.workflow-row.selected {
border-color: #00cae039;
background: linear-gradient(120deg, #00cae016, #00cae003);
border-color: color-mix(in srgb, var(--scene) 35%, transparent);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--scene) 12%, var(--bg-3)),
var(--bg-3)
);
}
.workflow-row.selected::before {
content: '';
@@ -306,7 +497,7 @@
height: 22px;
position: absolute;
left: 0;
background: var(--accent);
background: var(--scene);
border-radius: 3px;
}
.workflow-pick {
@@ -330,9 +521,9 @@
height: 32px;
flex-shrink: 0;
border-radius: 9px;
border: 1px solid #00cae01f;
color: var(--accent);
background: #00cae008;
border: 1px solid var(--line-2);
color: var(--scene);
background: var(--bg-3);
font-size: 16px;
font-style: normal;
}
@@ -383,7 +574,7 @@
opacity: 1;
}
.workflow-row:hover {
background-color: #ffffff05;
background-color: var(--bg-2);
}
.empty {
color: var(--text-3);
@@ -434,7 +625,7 @@
}
@media (max-width: 1100px) {
.workflow-rail {
width: 210px;
width: var(--list);
}
}
</style>
@@ -0,0 +1,157 @@
<script lang="ts">
import { assetUrl } from '$lib/api';
import { jobStatusInfo } from '$lib/jobStatus';
import { isVideoOutput } from '../generate/takes';
import type { Job } from '$lib/comfy/types';
let {
jobs,
selectedId,
onSelect,
onExpand,
collapsed = false,
}: {
collapsed?: boolean;
jobs: Job[];
selectedId: number | null;
onSelect: (job: Job) => void;
onExpand: () => void;
} = $props();
const sorted = $derived([...jobs].sort((a, b) => b.id - a.id));
</script>
<aside class="history-rail" class:collapsed aria-label="生成历史">
<header>历史</header>
{#if !collapsed}
<div class="history-list">
{#each sorted as job (job.id)}
{@const status = jobStatusInfo(job)}
<button
class="history-take"
class:selected={selectedId === job.id}
onclick={() => onSelect(job)}
aria-label={'查看历史 #' + job.id + ' · ' + status.label}
aria-pressed={selectedId === job.id}
>
<div class="thumb">
{#if job.output_paths[0] && status.tone === 'ok'}
{#if isVideoOutput(job, job.output_paths[0])}<span
class="video-mark">▷</span
><video
src={assetUrl(job.output_paths[0])}
muted
preload="metadata"
aria-label="视频缩略图"
></video>
{:else}<img
src={assetUrl(job.output_paths[0])}
alt=""
loading="lazy"
/>{/if}
{:else}<span class:failed={status.tone === 'fail'}
>{status.label}</span
>{/if}
</div>
<small>#{job.id}</small>
</button>
{/each}
{#if !sorted.length}<p>生成后<br />记录留在这里</p>{/if}
</div>
<footer><button onclick={onExpand}>全部记录</button></footer>
{/if}
</aside>
<style>
.history-rail {
box-sizing: border-box;
width: var(--hist);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--line);
background: var(--bg-1);
}
.history-rail.collapsed {
width: 0;
border: 0;
overflow: hidden;
visibility: hidden;
}
header {
height: var(--head);
display: grid;
place-items: center;
flex-shrink: 0;
border-bottom: 1px solid var(--line);
}
button {
font: inherit;
font-size: 11px;
background: none;
color: var(--tx-2);
border: 0;
cursor: pointer;
}
.history-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 6px;
}
.history-take {
display: block;
width: 100%;
padding: 3px;
margin-bottom: 8px;
border: 1px solid transparent;
border-radius: var(--r-sm);
}
.history-take.selected {
border-color: var(--scene);
background: color-mix(in srgb, var(--scene) 10%, transparent);
}
.thumb {
position: relative;
aspect-ratio: 1;
display: grid;
place-items: center;
background: var(--bg-0);
border-radius: var(--r-xs);
overflow: hidden;
font-size: 10px;
}
img,
video {
width: 100%;
height: 100%;
object-fit: contain;
position: absolute;
inset: 0;
}
.video-mark {
position: absolute;
z-index: 1;
color: var(--on-media);
font-size: 24px;
text-shadow: 0 1px 6px var(--media-scrim);
}
.failed {
color: var(--err);
}
small {
font-family: var(--mono);
font-size: 10px;
color: var(--tx-3);
}
p {
text-align: center;
font-size: 10px;
color: var(--tx-3);
line-height: 1.7;
}
footer {
padding: 10px 2px;
text-align: center;
border-top: 1px solid var(--line);
}
</style>
@@ -1,11 +1,16 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createHref } from '$lib/createHref';
import { isWorkflowRunnable } from '$lib/workflowState';
import { readDeskDraft, writeDeskDraft } from './draftCache';
import HistoryRail from './HistoryRail.svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { tick, untrack } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet, SvelteURL } from 'svelte/reactivity';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import Icon from '$lib/components/ui/Icon.svelte';
import '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
import Composer from '$lib/components/generate/Composer.svelte';
@@ -36,7 +41,7 @@
workbenchCtxOf,
type FeedFilter,
} from '$lib/components/generate/takes';
import InputRequirementBadge from './InputRequirementBadge.svelte';
import './InputRequirementBadge.svelte';
import WorkflowBay from './WorkflowBay.svelte';
import DeskWorkflowRail from './DeskWorkflowRail.svelte';
import {
@@ -121,7 +126,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(
@@ -131,6 +136,7 @@
// Full records, lazily fetched per id (selected workflow, feed jobs'
// workflows, the bay's preview row) and cached until invalidated.
const details = new SvelteMap<number, Workflow>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Nonreactive request gate: ensureDetail reads has synchronously in the effect; reactive delete on failure would re-arm requests without a cached detail.
const inflight = new Set<number>();
async function ensureDetail(id: number): Promise<Workflow | null> {
@@ -150,6 +156,7 @@
}
const neededIds = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Rebuilt from workflowId, bay selection and jobs on each derivation; this completed ID set is never mutated by consumers.
const ids = new Set<number>();
if (workflowId != null) ids.add(workflowId);
if (bayOpen && bayActiveId != null) ids.add(bayActiveId);
@@ -194,7 +201,7 @@
// Jobs enqueued from this desk — a fast failure can land before the
// jobs list refetch knows about them.
const submittedIds = new Set<number>();
const submittedIds = new SvelteSet<number>();
// D 批: one-shot completion flash / failure shake (SSE-driven) and the
// 5-second undo window for record deletes.
@@ -249,7 +256,7 @@
// Bay (挑选台) state — mode, deep links, open/collapse
// ------------------------------------------------------------------
type TaskMode = WorkflowUsage | 'all';
let mode = $state<TaskMode>(untrack(initMode));
const mode = $state<TaskMode>(untrack(initMode));
function initMode(): TaskMode {
const q = page.url.searchParams.get('mode');
return q && USAGE_BY_KIND[kind].includes(q as WorkflowUsage)
@@ -263,6 +270,8 @@
let paramEndpoint = $state('');
let paramShowErrors = $state(false);
let historyOpen = $state(false);
let historyCollapsed = $state(false);
let dockAnchor = $state<HTMLElement | null>(null);
let focusMode = $state(false);
let bayActiveId = $state<number | null>(null);
let bay = $state<WorkflowBay | null>(null);
@@ -276,7 +285,9 @@
function closeBay() {
bayOpen = false;
void tick().then(() =>
document.querySelector<HTMLButtonElement>('.model-selector')?.focus(),
document
.querySelector<HTMLButtonElement>('[data-workflow-details]')
?.focus(),
);
}
@@ -286,6 +297,7 @@
if (slotBusy || submitting) return;
actionRevision++;
selectedJobId = workflowResults.get(id) ?? null;
historyJobId = null;
if (id !== workflowId) workflowId = id;
void tick().then(() => promptEl?.focus());
}
@@ -308,18 +320,12 @@
workflowId = restoredWorkflowId;
return;
}
if (deskMetas.some((m) => m.id === raw)) workflowId = raw;
if (deskMetas.some((m) => m.id === raw)) {
if (pendingDraft?.workflowId !== raw) pendingDraft = null;
workflowId = raw;
}
});
async function toggleFavorite(id: number, fav: boolean) {
try {
await workflows.update(id, { favorite: fav });
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
await client.invalidateQueries({ queryKey: ['workflows'] });
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
// ------------------------------------------------------------------
// Form state
@@ -335,7 +341,6 @@
draftContext?.projectId,
draftContext?.stage,
draftContext?.sourceId ?? draftContext?.label,
page.url.searchParams.get('wf'),
]),
);
const sourceFingerprint = JSON.stringify([
@@ -343,9 +348,13 @@
draftContext?.negative,
draftContext?.durationSec,
]);
function readDraft(): { workflowId: number; values: FormValues } | null {
function readDraft(): {
workflowId: number;
values: FormValues;
requestId?: string | null;
} | null {
try {
const d = JSON.parse(window.sessionStorage.getItem(draftKey) ?? 'null');
const d = JSON.parse(readDeskDraft(draftKey) ?? 'null');
if (
!d ||
d.source !== sourceFingerprint ||
@@ -367,19 +376,27 @@
)
clean[key] = value;
}
return { workflowId: d.workflowId, values: clean };
return {
workflowId: d.workflowId,
values: clean,
requestId: d.requestId,
};
} catch {
return null;
}
}
let pendingDraft = untrack(readDraft);
// Keep the reload decision stable while detail and metadata queries arrive in either order.
const restoredWorkflowId = untrack(() =>
pendingDraft?.requestId === page.url.searchParams.get('wf')
? pendingDraft?.workflowId
: null,
);
const workflowDrafts = new SvelteMap<number, FormValues>();
const workflowEndpoints = new SvelteMap<number, string>();
const workflowResults = new SvelteMap<number, number>();
try {
const saved = JSON.parse(
window.sessionStorage.getItem(draftKey + ':workflows') ?? 'null',
);
const saved = JSON.parse(readDeskDraft(draftKey + ':workflows') ?? 'null');
if (saved?.source === sourceFingerprint) {
for (const [id, draft] of Object.entries(saved.drafts ?? {}))
if (Number.isInteger(Number(id)) && draft && typeof draft === 'object')
@@ -390,7 +407,7 @@
} catch {
/* The form works without storage. */
}
const restoredWorkflowId = pendingDraft?.workflowId ?? null;
let workflowId = $state<number | null>(pendingDraft?.workflowId ?? null);
let values = $state<FormValues>({});
let showErrors = $state(false);
@@ -456,6 +473,16 @@
...schemaDefaults(wf.input_schema ?? []),
...(workflowDrafts.get(wf.id) ?? {}),
};
if (lastWf && !workflowDrafts.has(wf.id)) {
const previous = classifySchema(lastWf.input_schema ?? [], lastWf.kind);
const incoming = classifySchema(wf.input_schema ?? [], wf.kind);
for (const role of ['prompt', 'negative'] as const) {
const oldInput = previous[role],
newInput = incoming[role];
if (oldInput && newInput && values[oldInput.nodeId] !== undefined)
next[newInput.nodeId] = values[oldInput.nodeId];
}
}
endpointTouched = true;
endpointId =
workflowEndpoints.get(wf.id) ?? wf.endpoint_id ?? activeId ?? '';
@@ -484,7 +511,7 @@
try {
workflowDrafts.set(wf.id, structuredClone($state.snapshot(inputs)));
workflowEndpoints.set(wf.id, ep);
window.sessionStorage.setItem(
writeDeskDraft(
draftKey + ':workflows',
JSON.stringify({
source: sourceFingerprint,
@@ -492,10 +519,11 @@
endpoints: Object.fromEntries(workflowEndpoints),
}),
);
window.sessionStorage.setItem(
writeDeskDraft(
draftKey,
JSON.stringify({
workflowId: wf.id,
requestId: page.url.searchParams.get('wf'),
values: inputs,
source: sourceFingerprint,
}),
@@ -671,6 +699,7 @@
return;
}
const wf = currentWorkflow!;
historyJobId = null;
submitting = true;
try {
const res = await playgroundApi.generate({
@@ -690,8 +719,6 @@
sentTimer = setTimeout(() => (sent = false), 900);
// C 批审核:从「参数」页提交后自动切到「结果」页,进行中进度可见。
tab = 'result';
if (wide) inspOff = false;
else if (!narrow) inspOpen = true;
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
// 最近使用由后端在入队时记录 (queue/manager);这里只刷新轻量列表。
@@ -710,30 +737,12 @@
// ------------------------------------------------------------------
let filter = $state<FeedFilter>(untrack(() => kind));
let selectedJobId = $state<number | null>(null);
let historyJobId = $state<number | null>(null);
let busyId = $state<number | null>(null);
let tab = $state<'result' | 'params'>('result');
let inspOff = $state(false);
let inspOpen = $state(false);
let wide = $state(true);
let narrow = $state(false);
let crossfade = $state(false);
let videoEl = $state<HTMLVideoElement | null>(null);
$effect(() => {
const mq = window.matchMedia('(min-width: 1180px)');
const mq2 = window.matchMedia('(max-width: 760px)');
const sync = () => {
wide = mq.matches;
narrow = mq2.matches;
};
sync();
mq.addEventListener('change', sync);
mq2.addEventListener('change', sync);
return () => {
mq.removeEventListener('change', sync);
mq2.removeEventListener('change', sync);
};
});
let nowMs = $state(Date.now());
const anyActive = $derived(
@@ -747,7 +756,8 @@
/** Picked take, else the newest finished one in view (undo-pending rows don't count). */
const selectedJob = $derived(
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
allJobs.find((j) => j.id === historyJobId && !pendingIds.has(j.id)) ??
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
allJobs
.filter(
(j) =>
@@ -766,13 +776,11 @@
*/
function selectTake(job: Job, via: 'pointer' | 'keyboard' = 'pointer') {
const from = via === 'pointer' ? (feed?.markRect() ?? null) : null;
selectedJobId = job.id;
if (workflowId != null && job.workflow_id === workflowId)
workflowResults.set(workflowId, job.id);
historyJobId = job.id;
tab = 'result';
crossfade = via === 'pointer';
if (via === 'pointer') {
if (!wide) inspOpen = true;
void tick().then(() => feed?.slideFrom(from));
} else {
feed?.scrollToJob(job.id);
@@ -842,11 +850,12 @@
);
function mediumLink(next: DeskKind) {
if (slotBusy || submitting) return;
const url = new URL(page.url);
url.pathname = '/' + next;
const url = new SvelteURL(page.url);
url.pathname = '/create';
url.searchParams.set('kind', next);
url.searchParams.delete('wf');
url.searchParams.delete('mode');
goto(url.pathname + url.search);
goto(resolve(createHref(url)));
}
const imageSlotTarget = $derived(
@@ -898,15 +907,60 @@
next[k] = v as string[];
}
if (slotBusy || submitting || revision !== actionRevision) return;
const stampedEndpoint = job.payload?.endpoint_id;
const reuseEndpoint =
typeof stampedEndpoint === 'string' &&
endpoints.some((ep) => ep.id === stampedEndpoint)
? stampedEndpoint
: (wf.endpoint_id ?? activeId ?? '');
if (wf.kind !== kind) {
const parts = JSON.parse(draftKey.slice('creative-draft:'.length));
parts[0] = wf.kind;
const targetKey = 'creative-draft:' + JSON.stringify(parts);
let saved: {
source?: string;
drafts?: Record<string, FormValues>;
endpoints?: Record<string, string>;
} = {};
try {
saved =
JSON.parse(readDeskDraft(targetKey + ':workflows') ?? '{}') ?? {};
} catch {
/* No prior draft. */
}
if (saved.source !== sourceFingerprint) saved = {};
writeDeskDraft(
targetKey + ':workflows',
JSON.stringify({
source: sourceFingerprint,
drafts: { ...saved.drafts, [wf.id]: next },
endpoints: { ...saved.endpoints, [wf.id]: reuseEndpoint },
}),
);
writeDeskDraft(
targetKey,
JSON.stringify({
workflowId: wf.id,
requestId: String(wf.id),
values: next,
source: sourceFingerprint,
}),
);
const url = new URL(page.url);
url.searchParams.set('kind', wf.kind);
url.searchParams.set('wf', String(wf.id));
await goto(resolve(createHref(url)));
return;
}
if (lastWf) workflowEndpoints.set(lastWf.id, endpointId);
endpointTouched = true;
endpointId =
workflowEndpoints.get(wf.id) ?? wf.endpoint_id ?? activeId ?? '';
endpointId = reuseEndpoint;
if (lastWf && lastWf.id !== wf.id)
workflowDrafts.set(lastWf.id, structuredClone($state.snapshot(values)));
workflowDrafts.set(wf.id, next);
workflowId = wf.id;
selectedJobId = job.id;
historyJobId = null;
workflowResults.set(wf.id, job.id);
lastWf = wf;
values = next;
@@ -1030,14 +1084,6 @@
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦搜索/提示词 · ↑↓/J/K 切换
// Ctrl/⌘+Enter 仅在创作输入区提交;Esc 退出选工具。媒体与记录使用显式控件。
// ------------------------------------------------------------------
function typing(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
return (
!!el &&
(el.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))
);
}
function inLayer(target: EventTarget | null): boolean {
const el = target instanceof Element ? target : null;
@@ -1104,10 +1150,17 @@
{/snippet}
<AppShell active={kind} title="创作台" workspace>
<div class="reference-desk" class:focused={focusMode}>
<div
class="reference-desk"
class:focused={focusMode}
style:--scene={kind === 'image' ? 'var(--sc-image)' : 'var(--sc-video)'}
>
<DeskWorkflowRail
{kind}
metas={deskMetas}
initialUsage={mode}
{historyCollapsed}
onToggleHistory={() => (historyCollapsed = !historyCollapsed)}
metas={allMetas.filter((m) => m.kind === kind)}
selectedId={workflowId}
{pendingCount}
onPick={pickWorkflow}
@@ -1117,28 +1170,53 @@
/>
<div class="refract-studio">
<header class="studio-heading">
<div class="heading-actions">
<button class="a-btn ghost sm" onclick={() => (historyOpen = true)}
>◷ 生成历史</button
><button id="desk-wf" class="model-selector" onclick={openBay}
>{currentWorkflow?.name ?? currentMeta?.name ?? '选择工作流'} ⌄</button
<div class="heading-actions workflow-identity">
<span class="fam-dot"
>{currentMeta?.model_family?.slice(0, 3) ??
(kind === 'image' ? '◈' : '▷')}</span
>
</div>
<div class="heading-actions">
{#if selectedJob && workbenchCtxOf(selectedJob) && selectedJob.status === 'done'}<button
class="a-btn secondary sm"
disabled={attachBusyId === selectedJob.id}
onclick={() => selectedJob && attachToProject(selectedJob)}
>保存到来源项目 ↗</button
<b id="desk-wf" class="model-selector"
>{currentWorkflow?.name ?? currentMeta?.name ?? '选择工作流'}</b
>
{#if currentMeta?.usage}<span class="a-tag"
>{usageLabel(currentMeta.usage)}</span
>{/if}
<button class="a-btn secondary sm" onclick={openParams}
>创作设置</button
><button class="a-btn ghost sm" onclick={toggleInspector}
>{focusMode ? '退出专注' : '专注创作'} ⤢</button
</div>
<span
class="workflow-file"
title={currentMeta?.source_path ?? '此工作流未记录源文件名'}
>{currentMeta?.source_path?.replaceAll('\\', '/').split('/').pop() ??
'源文件名未知'}</span
>
<div class="heading-actions">
<span
class="connection-status"
class:failed={connState.state === 'fail'}
>
<i class:connected={connState.state === 'ok'}></i>
{endpoints.find((e) => e.id === endpointId)?.name ?? '未选择连接'} · {connState.state ===
'ok' && connState.ms != null
? `${connState.ms}ms`
: connState.state === 'fail'
? '连接失败'
: '延迟未知'}
</span>
</div>
</header>
{@render ctxBanner()}
{#if historyJobId != null && selectedJob?.id === historyJobId}<div
class="history-banner"
role="status"
>
<span>正在查看历史 #{historyJobId} · 当前输入保持不变</span><button
class="a-btn ghost sm"
onclick={() => (historyJobId = null)}>返回当前创作</button
><button
class="a-btn secondary sm"
onclick={() => selectedJob && reuse(selectedJob)}
>复用到输入区</button
>
</div>{/if}
<div class="studio-layout">
<section class="viewer" aria-label="查看生成结果">
<div class="preview-area">
@@ -1157,6 +1235,11 @@
open={true}
inactive={false}
canSendToSlot={!!imageSlotTarget || !!imageList}
canAttach={!!selectedJob &&
!!workbenchCtxOf(selectedJob) &&
selectedJob.status === 'done'}
attachBusy={attachBusyId === selectedJob?.id}
onAttach={() => selectedJob && attachToProject(selectedJob)}
{crossfade}
bind:videoEl
params={paramsTab}
@@ -1186,7 +1269,11 @@
</div>{/if}
</div>
</section>
<aside class="studio-form" aria-label="提示词、素材与参数">
<aside
bind:this={dockAnchor}
class="studio-form"
aria-label="提示词、素材与参数"
>
<Composer
bind:this={composer}
dock
@@ -1219,6 +1306,13 @@
</aside>
</div>
</div>
<HistoryRail
collapsed={historyCollapsed}
jobs={allJobs.filter((j) => j.kind === kind && !pendingIds.has(j.id))}
selectedId={selectedJob?.id ?? null}
onSelect={selectTake}
onExpand={() => (historyOpen = true)}
/>
</div>
</AppShell>
<Modal bind:open={bayOpen} size="xl" title="工作流说明与管理"
@@ -1264,9 +1358,9 @@
<ul>
{#if inputs.prompt}<li>
画面描述 · {inputs.prompt.required ? '必填' : '可选'}
</li>{/if}{#each inputs.slots as slot}<li>
</li>{/if}{#each inputs.slots as slot (slot.inp.nodeId)}<li>
{slot.label} · {slot.inp.required ? '必填' : '可选'}
</li>{/each}{#each inputs.lists as list}<li>
</li>{/each}{#each inputs.lists as list (list.nodeId)}<li>
{list.label} · {list.required ? '必填' : '可选'}{list.max
? ',最多 ' + list.max + ' 个'
: ''}
@@ -1332,26 +1426,34 @@
</Modal>
<Modal
bind:open={paramsOpen}
anchor={dockAnchor}
size="lg"
title="创作设置"
onclose={() => (paramDraft = null)}
>
{#snippet headerActions()}<button
class="a-btn ghost sm"
onclick={resetParamDraft}>恢复工作流默认</button
>{/snippet}
{#snippet footer()}<div class="param-actions">
<span>保存后应用到当前工作流</span><button
class="a-btn secondary"
onclick={() => (paramsOpen = false)}>取消</button
><button class="a-btn purple" onclick={saveParams}>保存设置</button>
><button class="a-btn primary" onclick={saveParams}>保存设置</button>
</div>{/snippet}
<div class="reference-preferences">
<button class="a-btn ghost sm" onclick={toggleInspector}
>{focusMode ? '退出专注' : '专注创作'} ⤢</button
>
<div class="runtime-setting">
<label for="comfy-endpoint">使用哪台 ComfyUI</label><select
class="a-select"
id="comfy-endpoint"
bind:value={paramEndpoint}
>{#if !endpoints.length}<option value="">尚未配置服务</option
>{/if}{#each endpoints as ep}<option value={ep.id}>{ep.name}</option
>{/if}{#each endpoints as ep (ep.id)}<option value={ep.id}>{ep.name}</option
>{/each}</select
><a href="/connections">配置或检查连接 ↗</a>
><a href={resolve('/connections')}>配置或检查连接 ↗</a>
</div>
{@render paramsTab()}
</div>
@@ -1407,9 +1509,40 @@
justify-content: space-between;
align-items: center;
gap: 8px;
height: 44px;
height: var(--desk-head-height);
margin: 0 calc(-1 * var(--desk-head-inset));
padding: 0 var(--desk-head-inset);
box-sizing: border-box;
border-bottom: 1px solid var(--line);
background: var(--desk-head-bg);
backdrop-filter: blur(10px);
flex-shrink: 0;
}
@media (max-width: 1366px) {
.workflow-file {
display: none;
}
}
.history-banner {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 6px 8px;
border: 1px solid var(--info);
border-radius: var(--r-pill);
color: var(--info);
font-size: 12.5px;
background: color-mix(in srgb, var(--bg-2) 92%, transparent);
backdrop-filter: blur(8px);
box-shadow: var(--sh-2);
align-self: center;
max-width: 100%;
box-sizing: border-box;
}
.history-banner span {
margin-right: auto;
}
.heading-actions {
display: flex;
align-items: center;
@@ -1420,22 +1553,71 @@
border: 0;
background: none;
color: var(--text-2);
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
padding: 0;
font-size: var(--desk-head-name-size);
font-weight: var(--desk-head-name-weight);
line-height: var(--desk-head-name-line);
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-heading :global(.a-btn) {
min-height: 28px;
font-size: 11px;
padding: 4px 8px;
.workflow-identity {
flex-shrink: 1;
}
.fam-dot {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: var(--desk-search-radius);
display: grid;
place-items: center;
font: 700 9px var(--mono);
color: var(--bg-1);
background: var(--scene);
}
.workflow-file {
font: 11px var(--mono);
color: var(--tx-3);
max-width: 260px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: auto;
}
.connection-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
height: 26px;
padding: 0 10px;
border-radius: var(--desk-connection-radius);
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--tx-2);
white-space: nowrap;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-status i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--tx-3);
flex-shrink: 0;
}
.connection-status i.connected {
background: var(--ok);
}
.connection-status.failed i {
background: var(--err);
}
.studio-layout {
display: grid;
grid-template-rows: minmax(0, 1fr) 190px;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
flex: 1;
min-height: 0;
@@ -1462,7 +1644,7 @@
gap: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
background: var(--bg-1);
}
.desk-welcome h1 {
font-size: 24px;
@@ -1542,12 +1724,9 @@
}
@media (max-height: 740px) {
.studio-layout {
grid-template-rows: minmax(0, 1fr) 172px;
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
}
.refract-studio {
padding-bottom: 10px;
}
}
@media (max-width: 1100px) {
.refract-studio {
@@ -18,11 +18,8 @@
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
interface ModeEntry {
usage: WorkflowUsage;
count: number;
}
const modes = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local counting accumulator is consumed synchronously into mode rows; metas/kind drive the derivation.
const counts = new Map<WorkflowUsage, number>();
for (const m of metas) {
if (!m.usage) continue;
@@ -1,4 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { isWorkflowRunnable } from '$lib/workflowState';
import { tick } from 'svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { workflows, settings, apiErrorMessage } from '$lib/api';
@@ -25,7 +27,6 @@
pendingCount,
selectedId,
activeId = $bindable(null),
endpoints,
onPick,
onClose,
catalogue = false,
@@ -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(
@@ -173,7 +174,7 @@
recentOnly = false;
}}><span>全部工具</span><small>{metas.length}</small></button
>
{#each categories as cat}<button
{#each categories as cat (cat)}<button
class:active={category === cat}
onclick={() => {
category = cat;
@@ -217,9 +218,9 @@
>{/if}{#if source === 'local'}<select aria-label="筛选创作任务" bind:value={usage}
><option value="all">所有创作任务</option>{#each [...new Set(metas
.map((m) => m.usage)
.filter(Boolean))] as u}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
.filter(Boolean))] as u (u)}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
><select aria-label="筛选模型家族" bind:value={family}
><option value="all">全部模型</option>{#each families as f}<option value={f}>{f}</option
><option value="all">全部模型</option>{#each families as f (f)}<option value={f}>{f}</option
>{/each}</select
>{/if}<span>{source === 'local' ? filtered.length : cloudModels.length} 个结果</span>
</div>
@@ -228,14 +229,14 @@
<div class="cloud-context">
<h3>让专业搭档加入创作。</h3>
<p>使用已配置模型,在智能体或画布中创作。自定义多媒体接口与技能可在设置中管理。</p>
<a href="/canvas" class="a-btn primary">打开创作画布 ↗</a>
<a href={resolve('/canvas')} class="a-btn primary">打开创作画布 ↗</a>
</div>
{#each cloudModels as profile}<article class="workflow-card">
{#each cloudModels as profile (profile.id)}<article class="workflow-card">
<div class="family">{profile.model}<span>已配置模型</span></div>
<h3>{profile.name}</h3>
<a href="/agents" class="tool-link">交给智能体创作 ↗</a>
<a href={resolve('/agents')} class="tool-link">交给智能体创作 ↗</a>
</article>{:else}<p class="empty">
还没有匹配的模型。<a href="/models">配置模型 ↗</a>
还没有匹配的模型。<a href={resolve('/models')}>配置模型 ↗</a>
</p>{/each}
{:else}
{#each visible as m (m.id)}<article
@@ -272,7 +273,7 @@
onclick={() => favorite(m)}>{m.favorite ? '★' : '☆'}</button
>
{#if m.needs_mapping || !m.is_enabled || !m.usage}<a
href="/workflows?manage=1"
href={resolve('/workflows?manage=1')}
class="tool-link">{m.needs_mapping ? '完成映射' : '管理工作流'} ↗</a
>{/if}
</article>{:else}<p class="empty">没有找到匹配的工作流,试试更短的关键词。</p>{/each}
@@ -284,7 +285,7 @@
{/if}
</div>
{#if pendingCount > 0}<p class="pending">
另有 {pendingCount} 个工作流待映射。<a href="/workflows?manage=1">去整理 ↗</a>
另有 {pendingCount} 个工作流待映射。<a href={resolve('/workflows?manage=1')}>去整理 ↗</a>
</p>{/if}
</section>
</div>
@@ -0,0 +1,17 @@
// Same-tab fallback for browsers that deny session storage. No user data leaves the browser.
const drafts = new Map<string, string>();
export function readDeskDraft(key: string): string | null {
try {
return sessionStorage.getItem(key) ?? drafts.get(key) ?? null;
} catch {
return drafts.get(key) ?? null;
}
}
export function writeDeskDraft(key: string, value: string) {
drafts.set(key, value);
try {
sessionStorage.setItem(key, value);
} catch {
/* In-memory switching still works. */
}
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Modal from '$lib/components/ui/Modal.svelte';
import { tick } from 'svelte';
import { assetUrl } from '$lib/api';
@@ -63,7 +64,6 @@
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onOpenBay,
onPickEndpoint,
onSubmit,
onReveal,
@@ -73,6 +73,7 @@
}: Props = $props();
let mediaOpen = $state(false);
let expanded = $state(false);
let popoverInputs = $state<ComfyDynamicInput[]>([]);
let popoverAnchor = $state<HTMLElement | null>(null);
export function openMedia() {
@@ -138,7 +139,7 @@
}
</script>
<div class="composer-wrap" class:dock>
<div class="composer-wrap" class:dock class:expanded>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={root}
@@ -157,7 +158,10 @@
<div class="composer-scroll" aria-label="创作输入">
{@render header?.()}
{#if current}
<section class="input-section prompt-section">
<section
class="input-section prompt-section"
class:has-slots={dock && hasSlots}
>
<h3>2 · 准备这次创作</h3>
{#if hasSlots && !dock}<div class="slots">
<SlotTray
@@ -171,9 +175,9 @@
{onFromResults}
/>
</div>{/if}
{#if dock}<div class="media-summary">
{#if dock}<div class="media-summary" class:has-slots={hasSlots}>
{#if hasSlots}
{#each ds.slots.slice(0, 4) as slot}<button
{#each ds.slots.slice(0, 3) as slot (slot.inp.nodeId)}<button
class="media-token"
onclick={openMedia}
aria-label={'设置' + slot.label}
@@ -186,20 +190,36 @@
</button>{/each}
{#if ds.lists.length}<button
class="media-token"
onclick={openMedia}>+ 参考材料</button
onclick={openMedia}
>+ 参考材料 {ds.lists.reduce(
(n, g) =>
n +
(Array.isArray(values[g.nodeId])
? (values[g.nodeId] as string[]).length
: 0),
0,
)}</button
>{/if}
<button class="media-token" onclick={openMedia}
<button class="expand-prompt manage-media" onclick={openMedia}
>{slotBusy ? '正在上传…' : '管理素材'}</button
>
{:else}<small>描述你的画面</small>{/if}
<small class="input-shortcut">Ctrl + Enter 生成</small>
{/if}
</div>{/if}
<div class="prompt-pair" class:dual={dock && !!ds.negative}>
{#if ds.prompt}<div class="prompt-field">
<label for="desk-prompt"
>{ds.negative ? '正向提示词' : '提示词'}
{#if ds.prompt.required}<span>必填</span>{/if}</label
><textarea
<div class="prompt-label">
<label for="desk-prompt"
>{ds.negative ? '正向提示词' : '提示词'}
{#if ds.prompt.required}<span>必填</span>{/if}</label
>
<button
class="expand-prompt"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>{expanded ? '收起提示词' : '展开提示词'}</button
>
</div>
<textarea
bind:this={promptEl}
id="desk-prompt"
class="prompt"
@@ -293,9 +313,9 @@
onchange={(e) => onPickEndpoint(e.currentTarget.value)}
>
{#if !endpoints.length}<option value="">尚未配置服务</option>{/if}
{#each endpoints as ep}<option value={ep.id}>{ep.name}</option
{#each endpoints as ep (ep.id)}<option value={ep.id}>{ep.name}</option
>{/each}
</select><a href="/connections" target="_blank" rel="noreferrer"
</select><a href={resolve('/connections')} target="_blank" rel="noreferrer"
>配置或检查连接 ↗</a
>
</details>{/if}
@@ -306,7 +326,7 @@
>✦ {endpointName}⌄</button
>{#each commonInputs
.filter((i) => i !== ds.height)
.slice(0, 3) as inp}<button
.slice(0, 3) as inp (inp.nodeId)}<button
class="parameter"
onclick={(e) => openParameter(inp, e.currentTarget)}
>{inp.label}: {String(
@@ -333,7 +353,7 @@
disabled={!!block || submitting || slotBusy}
onclick={onSubmit}
>
<Icon name="sparkle" size={16} />{submitting
<Icon name="sparkle" size={14} />{submitting
? '正在提交…'
: slotBusy
? '正在上传素材…'
@@ -482,7 +502,7 @@
background: var(--surface);
display: grid;
gap: 10px;
box-shadow: 0 -8px 20px #292a2d05;
box-shadow: var(--sh-1);
}
.gen {
width: 100%;
@@ -520,24 +540,26 @@
}
.dock {
height: 100%;
height: auto;
min-height: 0;
}
.dock .composer {
height: 100%;
height: auto;
min-height: 0;
border: 1px solid #f5b04246;
border-radius: 16px;
background: linear-gradient(125deg, #1b1a1d, #17161a);
border: 1px solid var(--line-2);
border-radius: var(--desk-dock-radius);
background: var(--composer-bg);
overflow: hidden;
box-shadow: inset 0 0 0 1px #f5b04206;
box-shadow: var(--desk-dock-shadow);
}
.dock .composer:focus-within {
border-color: #f5b04280;
border-color: color-mix(in srgb, var(--gold) 45%, transparent);
box-shadow: var(--desk-dock-focus);
}
.dock .composer-scroll {
padding: 9px 14px 4px;
overflow: hidden;
scrollbar-gutter: auto;
padding: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
@@ -557,50 +579,65 @@
display: flex;
align-items: center;
gap: 6px;
height: 28px;
margin-bottom: 6px;
min-height: 0;
margin: 0;
flex-shrink: 0;
overflow: auto;
}
.media-summary > small {
color: var(--text-3);
font-size: 10px;
white-space: nowrap;
.dock .input-section.has-slots {
display: grid;
grid-template-columns: minmax(0, var(--desk-slot-column)) minmax(0, 1fr);
gap: 0;
}
.has-slots .media-summary {
align-self: start;
flex-wrap: wrap;
margin: 0;
padding: 10px 12px 8px;
border-right: 1px solid var(--line);
}
.media-token {
display: flex;
align-items: center;
gap: 5px;
background: #ffffff04;
border: 1px dashed #ffffff25;
background: var(--bg-2);
border: 1px dashed var(--line-3);
border-radius: 6px;
padding: 3px 7px;
font-size: 10px;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
height: 26px;
height: var(--slot-size);
}
.media-token img {
height: 20px;
width: 20px;
height: 36px;
width: 36px;
object-fit: cover;
border-radius: 3px;
}
.input-shortcut {
.expand-prompt {
margin-left: auto;
flex-shrink: 0;
border: 0;
background: none;
color: var(--tx-2);
font-size: 11px;
cursor: pointer;
}
.dock .prompt-pair {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
flex: 1;
gap: 18px;
gap: 0;
}
.dock .prompt-pair.dual {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.65fr);
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
}
.dock .prompt-field {
padding: 10px 14px 6px;
display: flex;
flex-direction: column;
min-height: 0;
@@ -608,32 +645,41 @@
}
.dock .negative {
border-left: 1px solid var(--line);
padding-left: 18px;
}
.dock .prompt-field label {
font-size: 10px;
font-size: 11.5px;
height: 20px;
color: var(--text-3);
margin: 0 0 4px;
margin: 0;
display: flex;
gap: 6px;
}
.prompt-label {
display: flex;
align-items: center;
justify-content: space-between;
height: 20px;
}
.dock .prompt-field:first-child label {
color: #d0b37d;
color: var(--tx-2);
}
.dock .prompt {
background: none;
border: 0;
border-radius: 0;
padding: 0;
font-size: 12px;
font-size: var(--fs-body);
line-height: 1.6;
resize: none;
min-height: 0;
max-height: none;
height: 100%;
flex: 1;
height: var(--prompt-height);
flex: none;
overflow: auto;
}
.dock.expanded .prompt {
height: var(--prompt-expanded);
}
.dock .prompt:focus {
outline: 0;
}
@@ -645,15 +691,18 @@
margin-top: 3px;
}
.dock .prompt-counter {
display: none;
height: 22px;
align-items: center;
font-size: 11px;
margin-top: 4px;
}
.dock .submit {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
padding: 7px 14px;
height: 45px;
padding: 8px 10px 10px 12px;
height: 57px;
box-sizing: border-box;
background: none;
border-top: 1px solid var(--line);
@@ -668,12 +717,12 @@
overflow: auto;
}
.parameter {
background: #25242a;
border: 1px solid #ffffff07;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 7px;
padding: 5px 8px;
font-size: 10px;
color: #c4c2cb;
color: var(--tx-2);
cursor: pointer;
white-space: nowrap;
max-width: 180px;
@@ -682,20 +731,18 @@
flex-shrink: 0;
}
.parameter:hover {
border-color: #f5b04255;
border-color: var(--line-3);
}
.dock .gen {
width: auto;
min-height: 32px;
font-size: 11px;
padding: 7px 18px;
border-radius: 20px;
width: var(--desk-gen-width);
height: var(--desk-gen-height);
min-height: var(--desk-gen-height);
font-size: 14px;
font-weight: var(--desk-gen-weight);
padding: 0 16px;
border-radius: var(--desk-gen-radius);
margin-left: auto;
flex-shrink: 0;
background: linear-gradient(140deg, #ffc65e, #f5b042);
color: #30210c;
border-color: #edb458;
box-shadow: 0 3px 12px #f5b04217;
}
.dock .why {
position: absolute;
@@ -704,14 +751,14 @@
max-width: 65%;
padding: 3px 8px;
border-radius: 5px;
background: #241e17;
background: var(--bg-2);
font-size: 10px;
color: var(--warn);
z-index: 1;
}
.dock .why.err {
color: var(--err);
background: #241717;
background: var(--bg-2);
}
.dock .ready {
display: none;
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import { backHref, type DeskContext } from './context';
@@ -29,7 +30,7 @@
<span class="note">
{canFlowBack ? t('desk.ctxBackfill', { stage: stageName }) : t('desk.ctxNoBackfill', { stage: stageName })}
</span>
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
<a class="a-btn ghost xs back" href={resolve(backHref(ctx))}>{t('desk.ctxBack', { stage: stageName })}</a>
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
<Icon name="close" size={12} />
</button>
@@ -62,7 +62,6 @@
open,
inactive,
canSendToSlot,
crossfade,
videoEl = $bindable(null),
params,
onClose,
@@ -337,7 +336,7 @@
</button>
{/if}
{#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
<a class="a-btn secondary sm" href={assetUrl(output)} download rel="external">{t('desk.download')}</a>
{/if}
{#if output && video}
<button
@@ -116,7 +116,9 @@
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
export function dropFiles(files: File[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Per-drop allocation buffer, synchronously consumed into groupAdd calls; no collection state survives this invocation.
const pending = new Map<string, File[]>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Per-drop reservation set prevents assigning two files to one slot before async upload completes; never used as render state.
const taken = new Set<string>();
let unplaced = 0;
for (const f of files) {
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
@@ -205,7 +206,7 @@
<span class="a-tag ok" title={t('desk.attachHint', { label: wbCtx.label ?? '' })}>
{t('desk.attachedTag')}
</span>
<a class="a-btn ghost xs" href={`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`}>
<a class="a-btn ghost xs" href={resolve(`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`)}>
{t('desk.attachBack')}
</a>
{/if}
@@ -217,7 +218,7 @@
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
<a class="a-btn ghost xs" href={assetUrl(output)} download rel="external">{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
@@ -125,6 +125,7 @@
// Entrance bookkeeping: only takes that appear AFTER the first fill
// (or come back from an undo) play the 220ms rise.
// ------------------------------------------------------------------
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Nonreactive entrance ledger read/written in the jobs effect and markEnter; keep bookkeeping outside effect dependencies, with animateIds driving classes.
const seenIds = new Set<number>();
const animateIds = new SvelteSet<number>();
let hydrated = false;
@@ -1,5 +1,5 @@
import type { WorkbenchStageKey } from '$lib/api';
import type { DeskKind } from './schema';
import type { WorkbenchStageKey } from "$lib/api";
import type { DeskKind } from "./schema";
/**
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
@@ -8,100 +8,119 @@ import type { DeskKind } from './schema';
* finished take can be flowed back to its source row (HEL-684).
*/
export interface DeskContext {
kind: DeskKind;
projectId: number;
stage: WorkbenchStageKey;
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
label: string;
prompt?: string;
negative?: string;
durationSec?: number;
/** Real source row identity — only present on links the workbench
* stamped with it; without it no flow-back is offered (nothing guessed). */
sourceKind?: 'asset' | 'shot';
sourceId?: string;
kind: DeskKind;
projectId: number;
stage: WorkbenchStageKey;
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
label: string;
prompt?: string;
negative?: string;
durationSec?: number;
/** Real source row identity — only present on links the workbench
* stamped with it; without it no flow-back is offered (nothing guessed). */
sourceKind?: "asset" | "shot";
sourceId?: string;
}
/** The generate-request context payload shape (HEL-684). */
export interface DeskWorkbenchContext {
project_id: number;
stage: 'assets' | 'shot';
source_kind: 'asset' | 'shot';
source_id: string;
label?: string;
project_id: number;
stage: "assets" | "shot";
source_kind: "asset" | "shot";
source_id: string;
label?: string;
}
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
const STAGES: WorkbenchStageKey[] = [
"script",
"style",
"storyboard",
"assets",
"shot",
];
/** Stage ↔ desk kind pairing the workbench rows render (assets → image,
* shot → video). Anything else cannot display a flowed-back take. */
const KIND_BY_STAGE: Partial<Record<WorkbenchStageKey, DeskKind>> = {
assets: 'image',
shot: 'video',
assets: "image",
shot: "video",
};
export function deskHref(ctx: DeskContext): string {
const q = new URLSearchParams({
from: 'workbench',
project: String(ctx.projectId),
stage: ctx.stage,
label: ctx.label,
});
if (ctx.prompt) q.set('prompt', ctx.prompt);
if (ctx.negative) q.set('negative', ctx.negative);
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
if (ctx.sourceKind && ctx.sourceId) {
q.set('source_kind', ctx.sourceKind);
q.set('source_id', ctx.sourceId);
}
return `/${ctx.kind}?${q.toString()}`;
export function deskHref(ctx: DeskContext): `/${DeskKind}?${string}` {
const q = new URLSearchParams({
from: "workbench",
project: String(ctx.projectId),
stage: ctx.stage,
label: ctx.label,
});
if (ctx.prompt) q.set("prompt", ctx.prompt);
if (ctx.negative) q.set("negative", ctx.negative);
if (ctx.durationSec != null) q.set("duration", String(ctx.durationSec));
if (ctx.sourceKind && ctx.sourceId) {
q.set("source_kind", ctx.sourceKind);
q.set("source_id", ctx.sourceId);
}
return `/${ctx.kind}?${q.toString()}`;
}
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
const q = url.searchParams;
if (q.get('from') !== 'workbench') return null;
const projectId = Number(q.get('project'));
const stage = q.get('stage') as WorkbenchStageKey | null;
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
const duration = Number(q.get('duration'));
const rawKind = q.get('source_kind');
const rawId = q.get('source_id') ?? '';
// Source identity is only kept when it is complete and self-consistent;
// old/hand-made links keep today's prefill-only behavior.
const expectedKind = stage === 'assets' ? 'asset' : stage === 'shot' ? 'shot' : null;
const sourceKind = rawKind === 'asset' || rawKind === 'shot' ? rawKind : null;
const hasSource = sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== '';
return {
kind,
projectId,
stage,
label: q.get('label') ?? '',
prompt: q.get('prompt') ?? undefined,
negative: q.get('negative') ?? undefined,
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
sourceKind: hasSource ? (sourceKind as 'asset' | 'shot') : undefined,
sourceId: hasSource ? rawId : undefined,
};
const q = url.searchParams;
if (q.get("from") !== "workbench") return null;
const projectId = Number(q.get("project"));
const stage = q.get("stage") as WorkbenchStageKey | null;
if (
!Number.isInteger(projectId) ||
projectId <= 0 ||
!stage ||
!STAGES.includes(stage)
)
return null;
const duration = Number(q.get("duration"));
const rawKind = q.get("source_kind");
const rawId = q.get("source_id") ?? "";
// Source identity is only kept when it is complete and self-consistent;
// old/hand-made links keep today's prefill-only behavior.
const expectedKind =
stage === "assets" ? "asset" : stage === "shot" ? "shot" : null;
const sourceKind = rawKind === "asset" || rawKind === "shot" ? rawKind : null;
const hasSource =
sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== "";
return {
kind,
projectId,
stage,
label: q.get("label") ?? "",
prompt: q.get("prompt") ?? undefined,
negative: q.get("negative") ?? undefined,
durationSec:
Number.isFinite(duration) && duration > 0 ? duration : undefined,
sourceKind: hasSource ? (sourceKind as "asset" | "shot") : undefined,
sourceId: hasSource ? rawId : undefined,
};
}
/**
* The context a generate request carries: only when the full, real source
* identity is present AND the desk kind matches what that stage renders.
*/
export function workbenchContextOf(ctx: DeskContext | null): DeskWorkbenchContext | undefined {
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
if (ctx.stage !== 'assets' && ctx.stage !== 'shot') return undefined;
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
const out: DeskWorkbenchContext = {
project_id: ctx.projectId,
stage: ctx.stage,
source_kind: ctx.sourceKind,
source_id: ctx.sourceId,
};
if (ctx.label.trim()) out.label = ctx.label;
return out;
export function workbenchContextOf(
ctx: DeskContext | null,
): DeskWorkbenchContext | undefined {
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
if (ctx.stage !== "assets" && ctx.stage !== "shot") return undefined;
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
const out: DeskWorkbenchContext = {
project_id: ctx.projectId,
stage: ctx.stage,
source_kind: ctx.sourceKind,
source_id: ctx.sourceId,
};
if (ctx.label.trim()) out.label = ctx.label;
return out;
}
export function backHref(ctx: DeskContext): string {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
export function backHref(
ctx: DeskContext,
): `/workbench/${string}?stage=${string}` {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
}
+396 -281
View File
@@ -1,7 +1,11 @@
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
import { normalizeInputRole } from '$lib/comfy/parser';
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
import type {
ComfyDynamicInput,
Workflow,
WorkflowUsage,
} from "$lib/comfy/types";
import { normalizeInputRole } from "$lib/comfy/parser";
import { ratioShort, resolutionSelectorSize } from "$lib/comfy/resolution";
import { t } from "$lib/i18n.svelte";
/**
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
@@ -9,366 +13,477 @@ import { t } from '$lib/i18n.svelte';
* never from model names. No wiring → no slot.
*/
export type DeskKind = 'image' | 'video';
export type DeskKind = "image" | "video";
export type FormValue = string | number | string[];
export type FormValues = Record<string, FormValue>;
export interface SlotDef {
inp: ComfyDynamicInput;
label: string;
mediaKind: 'image' | 'video' | 'audio';
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
firstFrame: boolean;
lastFrame: boolean;
mask: boolean;
inp: ComfyDynamicInput;
label: string;
mediaKind: "image" | "video" | "audio";
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
firstFrame: boolean;
lastFrame: boolean;
mask: boolean;
}
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
export interface BlockReason {
text: string;
tone: 'warn' | 'err';
/** Element id to focus when the reason is clicked. */
target?: string;
/** Open the params tab instead of focusing. */
params?: boolean;
text: string;
tone: "warn" | "err";
/** Element id to focus when the reason is clicked. */
target?: string;
/** Open the params tab instead of focusing. */
params?: boolean;
}
export interface DeskSchema {
prompt: ComfyDynamicInput | null;
negative: ComfyDynamicInput | null;
seed: ComfyDynamicInput | null;
width: ComfyDynamicInput | null;
height: ComfyDynamicInput | null;
resolution: ComfyDynamicInput | null;
duration: ComfyDynamicInput | null;
batch: ComfyDynamicInput | null;
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
ratio: ComfyDynamicInput | null;
megapixels: ComfyDynamicInput | null;
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
slots: SlotDef[];
/** Autogrow media lists actually wired (image / video / audio). */
lists: ComfyDynamicInput[];
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
common: ComfyDynamicInput[];
/** Everything else that is not media and not the prompt — folded. */
advanced: ComfyDynamicInput[];
hasH3Hint: boolean;
prompt: ComfyDynamicInput | null;
negative: ComfyDynamicInput | null;
seed: ComfyDynamicInput | null;
width: ComfyDynamicInput | null;
height: ComfyDynamicInput | null;
resolution: ComfyDynamicInput | null;
duration: ComfyDynamicInput | null;
batch: ComfyDynamicInput | null;
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
ratio: ComfyDynamicInput | null;
megapixels: ComfyDynamicInput | null;
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
slots: SlotDef[];
/** Autogrow media lists actually wired (image / video / audio). */
lists: ComfyDynamicInput[];
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
common: ComfyDynamicInput[];
/** Everything else that is not media and not the prompt — folded. */
advanced: ComfyDynamicInput[];
hasH3Hint: boolean;
}
export function inputRole(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
return normalizeInputRole(inp.role ?? null);
}
function hay(inp: ComfyDynamicInput): string {
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
return `${inp.role ?? ""} ${inp.label}`.toLowerCase();
}
export function isMedia(inp: ComfyDynamicInput): boolean {
return (
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'video' ||
inp.kind === 'audio' ||
inp.kind === 'image_list' ||
inp.kind === 'video_list' ||
inp.kind === 'audio_list'
);
return (
inp.kind === "image" ||
inp.kind === "image_url" ||
inp.kind === "video" ||
inp.kind === "audio" ||
inp.kind === "image_list" ||
inp.kind === "video_list" ||
inp.kind === "audio_list"
);
}
function isList(inp: ComfyDynamicInput): boolean {
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
return (
inp.kind === "image_list" ||
inp.kind === "video_list" ||
inp.kind === "audio_list"
);
}
function isMask(inp: ComfyDynamicInput): boolean {
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
return inputRole(inp) === "mask" || /mask|遮罩/.test(hay(inp));
}
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
// workflows still name frames only in the title.
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
return (
inputRole(inp) === "first_frame" ||
(!inp.auto && kind === "video" && /first|start|首帧/.test(hay(inp)))
);
}
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
return (
inputRole(inp) === "last_frame" ||
(!inp.auto && kind === "video" && /last|end|尾帧/.test(hay(inp)))
);
}
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
if (entry.kind === 'video_list') return 'video';
if (entry.kind === 'audio_list') return 'audio';
return 'image';
export function listKind(
entry: ComfyDynamicInput,
): "image" | "video" | "audio" {
if (entry.kind === "video_list") return "video";
if (entry.kind === "audio_list") return "audio";
return "image";
}
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
const v = values[entry.nodeId];
if (Array.isArray(v)) return v;
return typeof v === 'string' && v ? [v] : [];
export function listPaths(
values: FormValues,
entry: ComfyDynamicInput,
): string[] {
const v = values[entry.nodeId];
if (Array.isArray(v)) return v;
return typeof v === "string" && v ? [v] : [];
}
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
const byRole = (r: string) => plain((i) => inputRole(i) === r);
export function classifySchema(
schema: ComfyDynamicInput[],
kind: DeskKind,
): DeskSchema {
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
const byRole = (r: string) => plain((i) => inputRole(i) === r);
const prompt = byRole('prompt');
const negative = byRole('negative');
const seed = byRole('seed');
const width = byRole('width');
const height = byRole('height');
const resolution = byRole('resolution');
const duration = byRole('duration');
const batch = plain((i) => /批次|batch/.test(hay(i)));
const ratio =
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
const prompt = byRole("prompt");
const negative = byRole("negative");
const seed = byRole("seed");
const width = byRole("width");
const height = byRole("height");
const resolution = byRole("resolution");
const duration = byRole("duration");
const batch = plain((i) => /批次|batch/.test(hay(i)));
const ratio =
schema.find(
(i) =>
i.kind === "select" && inputRole(i) === "aspect_ratio" && i.resolution,
) ?? null;
const megapixels = ratio
? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null)
: null;
const media = schema.filter((i) => isMedia(i) && !isList(i));
const plainImages = media.filter(
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
);
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
const slots: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
if (inp.kind === 'video') {
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
} else if (inp.kind === 'audio') {
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
} else if (isMask(inp)) {
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
} else if (isFirstFrame(inp, kind)) {
slots.push({
...base,
firstFrame: true,
label: t('studio.slotFirst'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedFirstFrame'),
});
} else if (isLastFrame(inp, kind)) {
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
} else if (inp === editInput) {
slots.push({
...base,
label: t('studio.slotInput'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedInputImage'),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t('studio.slotRef')
: t('studio.slotRefN', { n: refIdx });
slots.push({
...base,
label,
mediaKind: 'image',
fromResults: true,
missing: t('studio.errUpload', { label }),
});
}
}
const media = schema.filter((i) => isMedia(i) && !isList(i));
const plainImages = media.filter(
(i) =>
i.kind === "image" &&
!isMask(i) &&
!isFirstFrame(i, kind) &&
!isLastFrame(i, kind),
);
const editInput =
kind === "image" && plainImages.length === 1 ? plainImages[0] : null;
const slots: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const base = {
inp,
missing: "",
firstFrame: false,
lastFrame: false,
mask: false,
};
if (inp.kind === "video") {
slots.push({
...base,
label: t("studio.slotRefVideo"),
mediaKind: "video",
fromResults: false,
});
} else if (inp.kind === "audio") {
slots.push({
...base,
label: t("studio.slotAudio"),
mediaKind: "audio",
fromResults: false,
});
} else if (isMask(inp)) {
slots.push({
...base,
mask: true,
label: t("studio.slotMask"),
mediaKind: "image",
fromResults: false,
});
} else if (isFirstFrame(inp, kind)) {
slots.push({
...base,
firstFrame: true,
label: t("studio.slotFirst"),
mediaKind: "image",
fromResults: true,
missing: t("studio.errNeedFirstFrame"),
});
} else if (isLastFrame(inp, kind)) {
slots.push({
...base,
lastFrame: true,
label: t("studio.slotLast"),
mediaKind: "image",
fromResults: true,
});
} else if (inp === editInput) {
slots.push({
...base,
label: t("studio.slotInput"),
mediaKind: "image",
fromResults: true,
missing: t("studio.errNeedInputImage"),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t("studio.slotRef")
: t("studio.slotRefN", { n: refIdx });
slots.push({
...base,
label,
mediaKind: "image",
fromResults: true,
missing: t("studio.errUpload", { label }),
});
}
}
const lists = schema.filter(isList);
const common = [ratio, width, height, resolution, duration, seed].filter(
(i): i is ComfyDynamicInput => !!i,
);
const commonIds = new Set(common.map((i) => i.nodeId));
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
const lists = schema.filter(isList);
const common = [ratio, width, height, resolution, duration, seed].filter(
(i): i is ComfyDynamicInput => !!i,
);
const commonIds = new Set(common.map((i) => i.nodeId));
const advanced = schema.filter(
(i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId),
);
return {
prompt,
negative,
seed,
width,
height,
resolution,
duration,
batch,
ratio,
megapixels,
slots,
lists,
common,
advanced,
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
};
return {
prompt,
negative,
seed,
width,
height,
resolution,
duration,
batch,
ratio,
megapixels,
slots,
lists,
common,
advanced,
hasH3Hint: lists.some((e) => e.media_hint === "minimax_h3_ref2va"),
};
}
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
const out: FormValues = {};
for (const inp of schema) {
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
}
return out;
const out: FormValues = {};
for (const inp of schema) {
if (inp.defaultValue !== undefined && inp.defaultValue !== "")
out[inp.nodeId] = inp.defaultValue;
}
return out;
}
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
const v = values[inp.nodeId];
const orig = inp.defaultValue;
if (v === undefined || v === '') return orig !== undefined && orig !== '';
if (orig === undefined || orig === '') return true;
return String(v) !== String(orig);
const v = values[inp.nodeId];
const orig = inp.defaultValue;
if (v === undefined || v === "") return orig !== undefined && orig !== "";
if (orig === undefined || orig === "") return true;
return String(v) !== String(orig);
}
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
export function changedParams(
ds: DeskSchema,
values: FormValues,
): ComfyDynamicInput[] {
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
}
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
if (!ds.ratio?.resolution) return null;
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
return resolutionSelectorSize(
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
mp,
ds.ratio.resolution.multiple,
);
export function outputSize(
ds: DeskSchema,
values: FormValues,
): { width: number; height: number } | null {
if (!ds.ratio?.resolution) return null;
const mp = Number(
values[ds.megapixels?.nodeId ?? ""] ?? ds.megapixels?.defaultValue,
);
return resolutionSelectorSize(
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ""),
mp,
ds.ratio.resolution.multiple,
);
}
export function batchCount(ds: DeskSchema, values: FormValues): number {
if (!ds.batch) return 1;
const n = Number(values[ds.batch.nodeId]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
if (!ds.batch) return 1;
const n = Number(values[ds.batch.nodeId]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
}
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
if (!ds.hasH3Hint) return [];
const warns: string[] = [];
const counts = { image: 0, video: 0, audio: 0 };
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
const total = counts.image + counts.video + counts.audio;
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
return warns;
if (!ds.hasH3Hint) return [];
const warns: string[] = [];
const counts = { image: 0, video: 0, audio: 0 };
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
const total = counts.image + counts.video + counts.audio;
if (total > 12) warns.push(t("wfMedia.h3TotalOver", { n: total }));
if (counts.audio > 0 && counts.image === 0 && counts.video === 0)
warns.push(t("wfMedia.h3AudioAlone"));
return warns;
}
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
const label = inp.label;
if (inp.kind === 'select') {
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
}
if (inp.kind !== 'number') return null;
const n = Number(raw);
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
return null;
export function valueError(
inp: ComfyDynamicInput,
raw: string | number,
): string | null {
const label = inp.label;
if (inp.kind === "select") {
return inp.options?.length && !inp.options.includes(String(raw))
? t("studio.errOption", { label })
: null;
}
if (inp.kind !== "number") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return t("studio.errNumber", { label });
if (inp.integer && !Number.isInteger(n))
return t("studio.errInteger", { label });
if (inp.min != null && n < inp.min)
return t("studio.errMin", { label, v: inp.min });
if (inp.max != null && n > inp.max)
return t("studio.errMax", { label, v: inp.max });
return null;
}
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
export function validateForm(
schema: ComfyDynamicInput[],
ds: DeskSchema,
values: FormValues,
schema: ComfyDynamicInput[],
ds: DeskSchema,
values: FormValues,
): Record<string, string> {
const errs: Record<string, string> = {};
if (ds.prompt?.required) {
const v = String(values[ds.prompt.nodeId] ?? '');
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
}
const first = ds.slots.find((s) => s.firstFrame);
const last = ds.slots.find((s) => s.lastFrame);
if (first && last) {
const a = String(values[first.inp.nodeId] ?? '');
const b = String(values[last.inp.nodeId] ?? '');
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
if (unpaired) {
errs.firstLast = t('studio.errFirstLast');
if (!a) errs[first.inp.nodeId] = ' ';
if (!b) errs[last.inp.nodeId] = ' ';
}
}
for (const s of ds.slots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? '').trim()) {
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
}
}
for (const g of ds.lists) {
const n = listPaths(values, g).length;
const max = g.max ?? 9;
if (n < (g.min ?? 1)) {
errs[g.nodeId] =
g.kind === 'image_list'
? t('studio.errNeedRef')
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
} else if (n > max) {
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
}
}
for (const inp of [...ds.common, ...ds.advanced]) {
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
}
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
return errs;
const errs: Record<string, string> = {};
if (ds.prompt?.required) {
const v = String(values[ds.prompt.nodeId] ?? "");
if (!v.trim()) errs[ds.prompt.nodeId] = t("studio.errPrompt");
else if (v.length > 2000)
errs[ds.prompt.nodeId] = t("studio.errPromptLong");
}
const first = ds.slots.find((s) => s.firstFrame);
const last = ds.slots.find((s) => s.lastFrame);
if (first && last) {
const a = String(values[first.inp.nodeId] ?? "");
const b = String(values[last.inp.nodeId] ?? "");
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
if (unpaired) {
errs.firstLast = t("studio.errFirstLast");
if (!a) errs[first.inp.nodeId] = " ";
if (!b) errs[last.inp.nodeId] = " ";
}
}
for (const s of ds.slots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? "").trim()) {
errs[s.inp.nodeId] =
s.missing || t("studio.errUpload", { label: s.label });
}
}
for (const g of ds.lists) {
const n = listPaths(values, g).length;
const max = g.max ?? 9;
if (n < (g.min ?? 1)) {
errs[g.nodeId] =
g.kind === "image_list"
? t("studio.errNeedRef")
: t("studio.errNeedRefMedia", { n: g.min ?? 1, label: g.label });
} else if (n > max) {
errs[g.nodeId] = t("studio.errRefMax", { max, n, k: n - max });
}
}
for (const inp of [...ds.common, ...ds.advanced]) {
if (inp.required && !String(values[inp.nodeId] ?? "").trim()) {
errs[inp.nodeId] = t("studio.errFill", { label: inp.label });
}
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (
raw === undefined ||
Array.isArray(raw) ||
String(raw).trim() === "" ||
errs[inp.nodeId]
)
continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
return errs;
}
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
const out: Array<{ nodeId: string; label: string }> = [];
for (const s of ds.slots) {
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
}
for (const g of ds.lists) {
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
}
return out;
export function missingMedia(
ds: DeskSchema,
values: FormValues,
): Array<{ nodeId: string; label: string }> {
const out: Array<{ nodeId: string; label: string }> = [];
for (const s of ds.slots) {
if (s.inp.required && !String(values[s.inp.nodeId] ?? "").trim())
out.push({ nodeId: s.inp.nodeId, label: s.label });
}
for (const g of ds.lists) {
if (listPaths(values, g).length < (g.min ?? 1))
out.push({ nodeId: g.nodeId, label: g.label });
}
return out;
}
export function usageLabel(u: WorkflowUsage): string {
return t(`usage.${u}`);
return t(`usage.${u}`);
}
/** Mode badge: the stored usage when we have one, else the wiring shape. */
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
if (wf?.usage) return usageLabel(wf.usage);
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
return t('studio.modeImageEdit');
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
export function modeLabel(
wf: Workflow | null,
ds: DeskSchema,
kind: DeskKind,
): string {
if (wf?.usage) return usageLabel(wf.usage);
if (ds.lists.some((g) => g.kind === "image_list"))
return t("studio.modeRefToVideo");
if (ds.slots.some((s) => s.firstFrame)) return t("studio.modeImageToVideo");
if (
kind === "image" &&
ds.slots.some((s) => s.mediaKind === "image" && !s.mask)
)
return t("studio.modeImageEdit");
if (ds.slots.length) return ds.slots.map((s) => s.label).join(" · ");
return kind === "image"
? t("studio.modeTextToImage")
: t("studio.modeTextToVideo");
}
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
export function slotSummary(wf: Workflow): string {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
const parts = [
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
];
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
const parts = [
...ds.slots.map((s) => t("desk.slotOne", { label: s.label })),
...ds.lists.map((g) =>
t("desk.slotRange", { label: g.label, min: g.min ?? 1, max: g.max ?? 9 }),
),
];
return parts.length ? parts.join(" · ") : t("desk.promptOnly");
}
/** Short chip text for one common parameter value. */
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
if (inp === ds.ratio) {
const size = outputSize(ds, values);
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
}
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
return v || inp.label;
export function chipValue(
inp: ComfyDynamicInput,
values: FormValues,
ds: DeskSchema,
): string {
const raw = values[inp.nodeId] ?? inp.defaultValue ?? "";
const v = Array.isArray(raw) ? raw.join(",") : String(raw);
if (inp === ds.ratio) {
const size = outputSize(ds, values);
return size
? `${ratioShort(v)} · ${size.width}×${size.height}`
: ratioShort(v);
}
if (inp === ds.duration)
return v ? t("desk.chipSeconds", { v }) : t("studio.durationLabel");
if (inp === ds.seed)
return t("desk.chipSeed", {
v: v.length > 8 ? `${v.slice(0, 8)}…` : v || "—",
});
if (inp === ds.resolution) return t("desk.chipResolution", { v: v || "—" });
return v || inp.label;
}
@@ -1,120 +1,120 @@
import { SvelteSet } from 'svelte/reactivity';
import type { QueryClient } from '@tanstack/svelte-query';
import { apiErrorMessage, playgroundApi } from '$lib/api';
import type { Job } from '$lib/comfy/types';
import { showToast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import type { QueryClient } from "@tanstack/svelte-query";
import { apiErrorMessage, playgroundApi } from "$lib/api";
import type { Job } from "$lib/comfy/types";
import { showToast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
/**
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
* take/task never opens a dialog. The row leaves the view right away, a
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
* that window closes without an undo — the backend removes result files,
* so an undo after the call would be impossible. Leaving the page (SPA
* route change or hard unload) flushes pending deletes instead of
* dropping them.
*/
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
* take/task never opens a dialog. The row leaves the view right away, a
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
* that window closes without an undo — the backend removes result files,
* so an undo after the call would be impossible. Leaving the page (SPA
* route change or hard unload) flushes pending deletes instead of
* dropping them.
*/
export interface TakeDeleteHandlers {
/** Localized toast message for this take. */
message: (job: Job) => string;
/** Undo: the row comes back — reselect / replay its entrance here. */
onUndo?: (job: Job) => void;
/** Called after the delete really hit the backend. */
onCommitted?: (job: Job) => void;
/** Localized toast message for this take. */
message: (job: Job) => string;
/** Undo: the row comes back — reselect / replay its entrance here. */
onUndo?: (job: Job) => void;
/** Called after the delete really hit the backend. */
onCommitted?: (job: Job) => void;
}
export interface TakeDeleteQueue {
/** Ids whose rows should be hidden (commit pending). */
readonly pending: SvelteSet<number>;
request: (job: Job, handlers: TakeDeleteHandlers) => void;
destroy: () => void;
/** Ids whose rows should be hidden (commit pending). */
readonly pending: SvelteSet<number>;
request: (job: Job, handlers: TakeDeleteHandlers) => void;
destroy: () => void;
}
const UNDO_MS = 5000;
export function createTakeDeleteQueue(client: QueryClient): TakeDeleteQueue {
const pending = new SvelteSet<number>();
const entries = new Map<
number,
{
job: Job;
handlers: TakeDeleteHandlers;
timer: ReturnType<typeof setTimeout>;
}
>();
let destroyed = false;
const pending = new SvelteSet<number>();
const entries = new SvelteMap<
number,
{
job: Job;
handlers: TakeDeleteHandlers;
timer: ReturnType<typeof setTimeout>;
}
>();
let destroyed = false;
async function invalidate() {
await Promise.allSettled([
client.invalidateQueries({ queryKey: ['playground-jobs'] }),
client.invalidateQueries({ queryKey: ['jobs'] }),
]);
}
async function invalidate() {
await Promise.allSettled([
client.invalidateQueries({ queryKey: ["playground-jobs"] }),
client.invalidateQueries({ queryKey: ["jobs"] }),
]);
}
async function commit(id: number) {
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
try {
await playgroundApi.deleteJob(id);
pending.delete(id);
entry.handlers.onCommitted?.(entry.job);
} catch (err) {
// Delete failed: the take stays — bring the row back honestly.
pending.delete(id);
showToast(
t('studio.deleteFailedToast', { detail: apiErrorMessage(err) }),
'error',
);
}
await invalidate();
}
async function commit(id: number) {
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
try {
await playgroundApi.deleteJob(id);
pending.delete(id);
entry.handlers.onCommitted?.(entry.job);
} catch (err) {
// Delete failed: the take stays — bring the row back honestly.
pending.delete(id);
showToast(
t("studio.deleteFailedToast", { detail: apiErrorMessage(err) }),
"error",
);
}
await invalidate();
}
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
function onPageHide() {
for (const [id] of entries) {
void fetch(`/api/playground/jobs/${id}`, {
method: 'DELETE',
keepalive: true,
});
}
}
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
function onPageHide() {
for (const [id] of entries) {
void fetch(`/api/playground/jobs/${id}`, {
method: "DELETE",
keepalive: true,
});
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
if (inBrowser) window.removeEventListener('pagehide', onPageHide);
// SPA route change: the JS context survives, normal commits are fine.
for (const [id] of entries) {
const entry = entries.get(id)!;
clearTimeout(entry.timer);
void commit(id);
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
if (inBrowser) window.removeEventListener("pagehide", onPageHide);
// SPA route change: the JS context survives, normal commits are fine.
for (const [id] of entries) {
const entry = entries.get(id)!;
clearTimeout(entry.timer);
void commit(id);
}
}
const inBrowser = typeof window !== 'undefined';
if (inBrowser) window.addEventListener('pagehide', onPageHide);
const inBrowser = typeof window !== "undefined";
if (inBrowser) window.addEventListener("pagehide", onPageHide);
return {
pending,
request(job, handlers) {
if (destroyed || pending.has(job.id)) return;
pending.add(job.id);
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
entries.set(job.id, { job, handlers, timer });
showToast(handlers.message(job), 'info', UNDO_MS, {
label: t('toast.undo'),
run: () => {
const e = entries.get(job.id);
if (!e) return;
clearTimeout(e.timer);
entries.delete(job.id);
pending.delete(job.id);
handlers.onUndo?.(job);
},
});
},
destroy,
};
return {
pending,
request(job, handlers) {
if (destroyed || pending.has(job.id)) return;
pending.add(job.id);
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
entries.set(job.id, { job, handlers, timer });
showToast(handlers.message(job), "info", UNDO_MS, {
label: t("toast.undo"),
run: () => {
const e = entries.get(job.id);
if (!e) return;
clearTimeout(e.timer);
entries.delete(job.id);
pending.delete(job.id);
handlers.onUndo?.(job);
},
});
},
destroy,
};
}
+147 -110
View File
@@ -1,157 +1,194 @@
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
import { jobStatusInfo } from '$lib/jobStatus';
import { resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
import type { Job, Workflow } from "$lib/comfy/types";
import { parseDbTs } from "$lib/dbtime";
import { jobStatusInfo } from "$lib/jobStatus";
import { resolutionSelectorSize } from "$lib/comfy/resolution";
import { t } from "$lib/i18n.svelte";
import {
classifySchema,
inputRole,
isFirstFrame,
isLastFrame,
isMedia,
} from "./schema";
/** Read-only views of a finished / running take (one playground job). */
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
export type FeedFilter = "all" | "image" | "video" | "failed";
/** The workbench source a take was generated with, if any (HEL-684). */
export interface TakeWorkbenchCtx {
projectId: number;
stage: string;
sourceKind: 'asset' | 'shot';
sourceId: string;
label?: string;
projectId: number;
stage: string;
sourceKind: "asset" | "shot";
sourceId: string;
label?: string;
}
export function workbenchCtxOf(job: Job): TakeWorkbenchCtx | null {
const stamp = job.payload?.workbench;
if (!stamp || typeof stamp !== 'object') return null;
const s = stamp as Record<string, unknown>;
const projectId = s.project_id;
const stage = typeof s.stage === 'string' ? s.stage : '';
const sourceKind = s.source_kind === 'asset' || s.source_kind === 'shot' ? s.source_kind : null;
const sourceId = typeof s.source_id === 'string' ? s.source_id : '';
if (typeof projectId !== 'number' || !stage || !sourceKind || !sourceId) return null;
const label = typeof s.label === 'string' && s.label.trim() ? s.label : undefined;
return {
projectId,
stage,
sourceKind,
sourceId,
label,
};
const stamp = job.payload?.workbench;
if (!stamp || typeof stamp !== "object") return null;
const s = stamp as Record<string, unknown>;
const projectId = s.project_id;
const stage = typeof s.stage === "string" ? s.stage : "";
const sourceKind =
s.source_kind === "asset" || s.source_kind === "shot"
? s.source_kind
: null;
const sourceId = typeof s.source_id === "string" ? s.source_id : "";
if (typeof projectId !== "number" || !stage || !sourceKind || !sourceId)
return null;
const label =
typeof s.label === "string" && s.label.trim() ? s.label : undefined;
return {
projectId,
stage,
sourceKind,
sourceId,
label,
};
}
export function jobValues(job: Job): Record<string, unknown> {
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
}
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
if (!wf) return null;
const vals = jobValues(job);
for (const inp of wf.input_schema ?? []) {
const r = inputRole(inp);
if (r && roles.includes(r)) {
const v = vals[inp.nodeId];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
}
}
return null;
export function jobValueByRole(
job: Job,
wf: Workflow | undefined,
...roles: string[]
): string | null {
if (!wf) return null;
const vals = jobValues(job);
for (const inp of wf.input_schema ?? []) {
const r = inputRole(inp);
if (r && roles.includes(r)) {
const v = vals[inp.nodeId];
if (v !== undefined && v !== null && String(v).trim() !== "")
return String(v);
}
}
return null;
}
export function promptOf(job: Job, wf: Workflow | undefined): string {
return jobValueByRole(job, wf, 'prompt') ?? '';
return jobValueByRole(job, wf, "prompt") ?? "";
}
export interface TakeInput {
label: string;
path: string;
kind: 'image' | 'video' | 'audio';
label: string;
path: string;
kind: "image" | "video" | "audio";
}
/** Media the take was generated from, in workflow order. */
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
if (!wf) return [];
const kind = wf.kind;
const vals = jobValues(job);
const out: TakeInput[] = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
if (!isMedia(inp)) continue;
const v = vals[inp.nodeId];
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
list.forEach((p, i) => {
if (typeof p !== 'string' || !p) return;
let label = inp.label;
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
out.push({ label, path: p, kind: mk });
});
}
return out;
if (!wf) return [];
const kind = wf.kind;
const vals = jobValues(job);
const out: TakeInput[] = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
if (!isMedia(inp)) continue;
const v = vals[inp.nodeId];
const list = Array.isArray(v) ? v : typeof v === "string" && v ? [v] : [];
const mk = inp.kind.startsWith("video")
? "video"
: inp.kind.startsWith("audio")
? "audio"
: "image";
list.forEach((p, i) => {
if (typeof p !== "string" || !p) return;
let label = inp.label;
if (inp.kind === "image_list") label = t("studio.slotRefN", { n: i + 1 });
else if (isFirstFrame(inp, kind)) label = t("studio.slotFirst");
else if (isLastFrame(inp, kind)) label = t("studio.slotLast");
else if (inp.kind === "image" && inputRole(inp) !== "mask")
label = t("studio.slotRefN", { n: ++refIdx });
out.push({ label, path: p, kind: mk });
});
}
return out;
}
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
const out: Array<{ label: string; value: string }> = [];
if (!wf) return out;
const w = jobValueByRole(job, wf, 'width');
const h = jobValueByRole(job, wf, 'height');
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
else {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
if (ds.ratio?.resolution) {
const vals = jobValues(job);
const size = resolutionSelectorSize(
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
ds.ratio.resolution.multiple,
);
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
}
}
const seed = jobValueByRole(job, wf, 'seed');
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
const dur = jobValueByRole(job, wf, 'duration');
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
return out;
export function specOf(
job: Job,
wf: Workflow | undefined,
): Array<{ label: string; value: string }> {
const out: Array<{ label: string; value: string }> = [];
if (!wf) return out;
const w = jobValueByRole(job, wf, "width");
const h = jobValueByRole(job, wf, "height");
if (w && h) out.push({ label: t("desk.specSize"), value: `${w}×${h}` });
else {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
if (ds.ratio?.resolution) {
const vals = jobValues(job);
const size = resolutionSelectorSize(
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ""),
Number(
vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue,
),
ds.ratio.resolution.multiple,
);
if (size)
out.push({
label: t("desk.specSize"),
value: `${size.width}×${size.height}`,
});
}
}
const seed = jobValueByRole(job, wf, "seed");
if (seed) out.push({ label: t("studio.seedLabel"), value: seed });
const dur = jobValueByRole(job, wf, "duration");
if (dur)
out.push({
label: t("studio.durationLabel"),
value: t("desk.chipSeconds", { v: dur }),
});
return out;
}
export function isVideoOutput(job: Job, path: string): boolean {
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
return job.kind === "video" && !/\.(png|jpe?g|webp)$/i.test(path);
}
export function elapsedSec(job: Job, nowMs: number): number | null {
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
if (!Number.isFinite(from)) return null;
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
return Math.max(0, (to - from) / 1000);
const from = job.started_at
? parseDbTs(job.started_at)
: parseDbTs(job.created_at);
if (!Number.isFinite(from)) return null;
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
return Math.max(0, (to - from) / 1000);
}
export function fmtDur(sec: number | null): string {
if (sec == null) return '—';
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
if (sec == null) return "—";
if (sec < 60) return t("desk.durSec", { s: Math.floor(sec) });
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return t("desk.durMin", { m, s: String(s).padStart(2, "0") });
}
export function fmtClock(ms: number): string {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
/** 今天 / 昨天 / 9月28日 heading for the feed. */
export function dayLabel(ms: number, nowMs: number): string {
const d = new Date(ms);
const today = new Date(nowMs);
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
if (diff === 0) return t('desk.today');
if (diff === 1) return t('desk.yesterday');
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
const d = new Date(ms);
const today = new Date(nowMs);
const startOf = (x: Date) =>
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
if (diff === 0) return t("desk.today");
if (diff === 1) return t("desk.yesterday");
return t("desk.dayDate", { m: d.getMonth() + 1, d: d.getDate() });
}
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
if (filter === 'all') return true;
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
return job.kind === filter;
if (filter === "all") return true;
if (filter === "failed") return jobStatusInfo(job).tone === "fail";
return job.kind === filter;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, projects, type Project } from '$lib/api';
@@ -186,7 +187,7 @@
type="button"
class="card-main"
aria-label={t('projectCard.open', { title: project.title })}
onclick={() => goto(`/workbench/${project.id}`)}
onclick={() => goto(resolve(`/workbench/${project.id}`))}
>
<span class="cover">
{#if coverUrl && !coverFailed}
@@ -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 (item.name)}<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,646 @@
<script lang="ts">
import CloudModels from "./CloudModels.svelte";
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { SvelteSet } from "svelte/reactivity";
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 SvelteSet<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,834 @@
<script lang="ts">
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { SvelteSet } from "svelte/reactivity";
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 SvelteSet<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,44 @@
<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?.()}
<!-- The approved header reserves this inline width independently of demo copy. -->
<span class="mg-header-reserve" aria-hidden="true"
><span></span><span>&emsp;&emsp;&emsp;&emsp;</span></span
>
</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,854 @@
/* 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: var(--manage-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;
box-sizing: var(--manage-cell-box, content-box);
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 {
box-sizing: var(--manage-cell-box, content-box);
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;
}
/* Confirmed full-width task table. Shared table/controls remain the single source. */
.task-table {
--manage-table-layout: var(--task-table-layout);
--manage-cell-box: border-box;
}
.task-thumbnail-column {
width: var(--task-thumbnail-column);
}
.task-progress-column {
width: var(--task-progress-column);
}
.task-tabs {
width: var(--task-tabs-width);
margin-left: var(--task-tabs-offset);
}
.task-search {
width: var(--task-search-width);
}
.task-thumbnail {
display: grid;
place-items: center;
width: var(--task-thumbnail-width);
height: var(--task-thumbnail-height);
border-radius: var(--task-thumbnail-radius);
border: 0;
padding: 0;
overflow: hidden;
color: var(--tx-3);
background: var(--bg-2);
cursor: pointer;
}
.task-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.task-name {
font-weight: 500;
}
.task-progress {
height: var(--task-progress-height);
margin-top: var(--task-progress-gap);
border-radius: var(--task-progress-radius);
background: rgb(var(--ink) / 0.1);
overflow: hidden;
}
.task-progress i {
display: block;
height: 100%;
background: var(--task-progress-fill);
box-shadow: var(--task-progress-shadow);
transition: width var(--task-progress-duration) linear;
}
.task-progress-unknown {
color: var(--tx-3);
margin-left: var(--task-progress-gap);
}
.task-table .mg-tag.run,
.task-dialog .mg-tag.run {
color: var(--task-running-color);
border-color: var(--task-running-border);
background: var(--task-running-background);
}
.task-dialog {
height: auto;
overflow: visible;
gap: 12px;
}
.task-output {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-output img,
.task-output video {
width: 100%;
max-height: var(--task-preview-height);
object-fit: contain;
background: var(--media-bg);
}
.mg-header-reserve {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
padding: 0 var(--manage-header-reserve-padding);
border: 1px solid transparent;
font: var(--manage-header-reserve-font);
letter-spacing: inherit;
}
.mg-header-reserve > :first-child {
width: var(--manage-header-reserve-dot);
}
.mg-header-reserve > :last-child {
white-space: pre;
}
@@ -166,7 +166,6 @@
>
<div class="thumb">
{#if isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={src} muted playsinline preload="metadata" onerror={hideBroken}></video>
{:else if (opt.kind ?? kind) === 'audio'}
<Icon name="music" size={28} />
@@ -78,6 +78,7 @@
// The same path can arrive twice (e.g. a scene clip that was also
// uploaded to the playground). Duplicate keys crash the keyed each
// in the picker grid, so dedupe by path first.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local path deduplication scratch set for one filter pass; only the filtered asset array escapes.
const seen = new Set<string>();
return assetOptions.filter((o) => {
const match =
@@ -144,7 +145,6 @@
{:else if value && thumbSrc && isImageKind}
<img class="tile-thumb" src={thumbSrc} alt={displayLabel} onerror={hideBrokenThumb} />
{:else if value && isVideoKind && thumbSrc}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="tile-thumb" src={thumbSrc} muted playsinline></video>
{:else if value}
<div class="tile-file">
@@ -63,6 +63,7 @@
if (!open || !panelEl) return;
document.body.appendChild(panelEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates panel contents; effect owns body portal placement and teardown. Remove the moved root on close/unmount; listener cleanup stays separate.
panelEl?.remove();
};
});
@@ -85,6 +85,7 @@
if (!open || !menuEl) return;
document.body.appendChild(menuEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates menu contents; effect owns body portal placement and teardown. Remove the moved root on close/unmount; selection and listener cleanup stay intact.
menuEl?.remove();
};
});
File diff suppressed because it is too large Load Diff
@@ -1,218 +1,234 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import type { IconName } from '$lib/components/ui/icons';
import { NAV } from './nav';
import type { Pathname } from "$app/types";
import { resolve } from "$app/paths";
import { goto } from "$app/navigation";
import { createQuery } from "@tanstack/svelte-query";
import { toStore } from "svelte/store";
import { projects } from "$lib/api";
import { t } from "$lib/i18n.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import type { IconName } from "$lib/components/ui/icons";
import { NAV } from "./nav";
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
interface Entry {
key: string;
group: 'pages' | 'projects';
label: string;
icon: IconName;
href: string;
}
interface Entry {
key: string;
group: "pages" | "projects";
label: string;
icon: IconName;
href: Pathname;
}
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state('');
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state("");
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ["projects"],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.id}`,
group: 'pages',
label: t(item.labelKey),
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: 'projects',
label: p.title,
icon: 'story',
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.href}`,
group: "pages",
label: item.label,
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: "projects",
label: p.title,
icon: "story",
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
if (!open) query = '';
});
$effect(() => {
if (!open) query = "";
});
function optionId(index: number) {
return `${listId}-${index}`;
}
function optionId(index: number) {
return `${listId}-${index}`;
}
function go(entry: Entry) {
open = false;
void goto(entry.href);
}
function go(entry: Entry) {
open = false;
void goto(resolve(entry.href));
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list
?.querySelector(`#${optionId(activeIndex)}`)
?.scrollIntoView({ block: "nearest" });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
move(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
move(-1);
} else if (event.key === 'Enter') {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
move(-1);
} else if (event.key === "Enter") {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
</script>
<Modal bind:open title={t('shell.searchTitle')}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t('shell.searchPlaceholder')}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
aria-label={t('shell.searchTitle')}
onkeydown={handleKeydown}
/>
</div>
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
{#if entries.length === 0}
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
</p>
</div>
<Modal bind:open title={t("shell.searchTitle")}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t("shell.searchPlaceholder")}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length
? optionId(activeIndex)
: undefined}
aria-label={t("shell.searchTitle")}
onkeydown={handleKeydown}
/>
</div>
<div
class="palette-list"
id={listId}
role="listbox"
bind:this={list}
aria-label={t("shell.searchTitle")}
>
{#if entries.length === 0}
<p class="palette-empty">
{t("shell.searchEmpty", { q: query.trim() })}
</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === "pages"
? t("shell.searchGroupPages")
: t("shell.searchGroupProjects")}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t("shell.searchHintMove")}
<span class="a-kbd">Enter</span>{t("shell.searchHintOpen")}
<span class="a-kbd">Esc</span>{t("shell.searchHintClose")}
</p>
</div>
</Modal>
<style>
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
@@ -56,7 +57,7 @@
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
<a
class="a-menu-item"
href="/"
href={resolve('/')}
aria-current={onProjectsList ? 'page' : undefined}
onclick={() => (open = false)}
>
@@ -70,7 +71,7 @@
{:else if $projectsQuery.isError}
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
{:else if recent.length === 0}
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
<a class="a-menu-item" href={resolve('/')} onclick={() => (open = false)}>
<Icon name="plus" size={15} />
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
</a>
@@ -78,7 +79,7 @@
{#each recent as project (project.id)}
<a
class="a-menu-item"
href="/workbench/{project.id}"
href={resolve(`/workbench/${project.id}`)}
aria-current={project.id === projectId ? 'true' : undefined}
onclick={() => (open = false)}
>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { workflows, type Job } from '$lib/api';
@@ -93,7 +94,7 @@
{:else}
{#each recent as job (job.id)}
{@const info = jobStatusInfo(job)}
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
<a class="a-menu-item tray-row" href={resolve('/tasks')} title={info.detail} onclick={() => (open = false)}>
<span class="tray-copy">
<span class="a-menu-label">{workflowName(job)}</span>
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
@@ -102,7 +103,7 @@
</a>
{/each}
{/if}
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
<a class="a-menu-item tray-all" href={resolve('/tasks')} onclick={() => (open = false)}>
<span class="a-menu-label">{t('shell.trayAll')}</span>
</a>
</ShellPop>
@@ -103,7 +103,8 @@
return Number.isFinite(ms) ? ms : fallback;
}
function popIn(_node: Element) {
function popIn(node: Element) {
void node;
const reduced = document.documentElement.dataset.reduce === '1';
const sheet = window.matchMedia(SHEET_QUERY).matches;
if (reduced) {
@@ -123,7 +124,8 @@
};
}
function fadeScrim(_node: Element) {
function fadeScrim(node: Element) {
void node;
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
}
</script>
@@ -0,0 +1,246 @@
<script lang="ts">
import { THEMES, theme, setTheme, themeStorageUnavailable } from "$lib/theme";
let {
compact = false,
tiles = false,
}: { compact?: boolean; tiles?: boolean } = $props();
function move(event: KeyboardEvent) {
const steps: Record<string, number> = {
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
};
const step = steps[event.key];
if (!step && event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const group = (event.currentTarget as HTMLElement).closest(
".choices",
) as HTMLElement;
const buttons = [...group.querySelectorAll<HTMLButtonElement>("button")];
const current = buttons.indexOf(
document.activeElement as HTMLButtonElement,
);
const next =
event.key === "Home"
? 0
: event.key === "End"
? buttons.length - 1
: (current + step + buttons.length) % buttons.length;
buttons[next]?.focus();
}
</script>
<div
class="choices"
class:compact
class:tiles
role="group"
aria-label="外观主题"
>
{#each THEMES as item (item.id)}
<button
class="choice"
class:selected={$theme === item.id}
aria-pressed={$theme === item.id}
onkeydown={move}
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
>{#if tiles}<em></em>{/if}</b
></span
></span
>
<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>
{/each}
</div>
{#if $themeStorageUnavailable}<p class="notice" role="status">
浏览器禁止本地存储:本次有效,其他操作不受影响
</p>{/if}
<style>
.choices {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.choices.tiles {
--choice-check-width: 0px;
--choice-display: flex;
--choice-box-sizing: border-box;
--choice-gap: 6px;
--choice-copy-gap: 6px;
--choice-tone-align: stretch;
--choice-preview-art: var(--theme-preview-art);
--choice-dock-width: 100%;
--choice-dock-background: var(--bg-3);
--choice-radius: 12px;
--choice-name-size: var(--manage-theme-name-size);
--choice-name-line: var(--manage-theme-line);
--choice-tone-font: var(--manage-theme-tone-size)/var(--manage-theme-line)
var(--font);
--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: var(--choice-display, grid);
flex-direction: column;
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(--choice-radius, var(--r-md));
background: var(--bg-2);
color: var(--tx-1);
text-align: left;
cursor: pointer;
}
.choice:hover {
border-color: var(--line-3);
background: var(--bg-3);
}
.choice.selected {
border-color: var(--choice-selected, var(--gold));
box-shadow: var(--choice-shadow, none);
}
.choice:focus-visible {
outline: 2px solid var(--info);
outline-offset: 2px;
}
.compact .choice {
grid-template-columns: 76px minmax(0, 1fr) 18px;
align-items: center;
}
.preview {
box-sizing: var(--choice-box-sizing, content-box);
grid-column: 1 / -1;
height: 64px;
border-radius: var(--r-sm);
overflow: hidden;
display: grid;
grid-template-columns: 12px 22px 1fr;
background: var(--bg-0);
border: 1px solid var(--line-2);
}
.compact .preview {
grid-column: auto;
height: 48px;
}
.rail,
.list {
background: var(--bg-1);
border-right: 1px solid var(--line);
}
.rail::before {
content: "";
display: block;
width: 4px;
height: 4px;
margin: 6px auto;
border-radius: 50%;
background: var(--gold);
}
.list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 3px;
}
.list i {
height: 4px;
border-radius: 2px;
background: var(--line-3);
}
.list i:first-child {
background: var(--sc-image);
}
.stage {
padding: 5px 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stage i {
flex: 1;
border-radius: 3px;
background: var(--choice-preview-art, var(--bg-3));
border: 1px solid var(--line-2);
}
.stage b {
height: 9px;
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: var(--choice-copy-gap, 2px) 6px;
min-width: 0;
}
.copy strong {
font-size: var(--choice-name-size, 13px);
line-height: var(--choice-name-line, inherit);
}
.copy small {
font: var(--choice-tone-font, 10px var(--mono));
color: var(--tx-3);
align-self: var(--choice-tone-align, center);
}
.copy > span {
width: 100%;
font-size: 11px;
color: var(--tx-2);
}
.check {
color: var(--gold);
}
.notice {
font-size: 12px;
color: var(--tx-2);
margin: 12px 0 0;
}
@media (max-width: 1366px) {
.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>
+123 -115
View File
@@ -1,123 +1,131 @@
import type { IconName } from '$lib/components/ui/icons';
import type { Pathname } from "$app/types";
import type { IconName } from "$lib/components/ui/icons";
// IDs are retained for the existing page contracts while B1–B4 replace layouts.
export type NavId =
| 'projects'
| 'workbench'
| 'image'
| 'video'
| 'tasks'
| 'agents'
| 'connections'
| 'models'
| 'library'
| 'settings'
| 'canvas'
| 'workflows';
| "projects"
| "workbench"
| "image"
| "video"
| "tasks"
| "agents"
| "connections"
| "models"
| "library"
| "settings"
| "canvas"
| "workflows";
export interface NavItem {
id: NavId;
labelKey: string;
icon: IconName;
href: string;
id: NavId;
label: string;
icon: IconName;
href: Pathname;
scene: "project" | "image" | "agent" | "manage" | "asset" | "gold";
matches: (path: string) => boolean;
}
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
// and settings entries at the foot of the 60px rail.
export const NAV_MAIN: NavItem[] = [
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
{
id: 'workbench',
labelKey: 'shell.navWorkbench',
icon: 'story',
href: '/workbench',
},
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
{ id: 'canvas', labelKey: 'nav.canvas', icon: 'story', href: '/canvas' },
{
id: 'agents',
labelKey: 'shell.navAgents',
icon: 'sparkle',
href: '/agents',
},
{
id: "projects",
label: "工作室",
icon: "nav-home",
href: "/",
scene: "gold",
matches: (p) => p === "/",
},
{
id: "workbench",
label: "项目",
icon: "nav-project",
href: "/workbench",
scene: "project",
matches: (p) => /^\/(workbench|project)(\/|$)/.test(p),
},
{
id: "image",
label: "创作台",
icon: "nav-create",
href: "/create",
scene: "image",
matches: (p) =>
["/create", "/image", "/video", "/playground", "/build-scene"].includes(
p,
),
},
{
id: "agents",
label: "智能体",
icon: "nav-agent",
href: "/agents",
scene: "agent",
matches: (p) => p === "/agents",
},
{
id: "canvas",
label: "画布",
icon: "nav-canvas",
href: "/canvas",
scene: "image",
matches: (p) => p.startsWith("/canvas"),
},
{
id: "library",
label: "素材库",
icon: "nav-lib",
href: "/library",
scene: "gold",
matches: (p) => p === "/library",
},
];
export const NAV_MANAGE: NavItem[] = [
{
id: "tasks",
label: "任务",
icon: "nav-queue",
href: "/tasks",
scene: "manage",
matches: (p) => p === "/tasks",
},
{
id: "workflows",
label: "工作流",
icon: "nav-flow",
href: "/workflows",
scene: "manage",
matches: (p) => p === "/workflows",
},
{
id: "connections",
label: "模型连接",
icon: "nav-plug",
href: "/model-connections",
scene: "manage",
matches: (p) =>
["/connections", "/models", "/model-connections"].includes(p),
},
];
export const NAV_FOOT: NavItem[] = [
{
id: 'connections',
labelKey: 'shell.navConnections',
icon: 'link',
href: '/connections',
},
{
id: 'models',
labelKey: 'shell.navModels',
icon: 'assets',
href: '/models',
},
{
id: 'workflows',
labelKey: 'connections.wfTitle',
icon: 'playground',
href: '/workflows',
},
{
id: 'library',
labelKey: 'shell.navLibrary',
icon: 'folder',
href: '/library',
},
{
id: 'settings',
labelKey: 'shell.navSettings',
icon: 'settings',
href: '/settings',
},
{
id: "settings",
label: "设置",
icon: "nav-gear",
href: "/settings",
scene: "manage",
matches: (p) => p === "/settings",
},
];
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
export interface TabItem {
id: 'projects' | 'workbench' | 'generate';
labelKey: string;
icon: IconName;
href: string;
/** Pages that light this tab up. */
covers: NavId[];
}
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
export const TABS: TabItem[] = [
{
id: 'projects',
labelKey: 'shell.tabProjects',
icon: 'home',
href: '/',
covers: ['projects'],
},
{
id: 'workbench',
labelKey: 'shell.tabCreate',
icon: 'story',
href: '/workbench',
covers: ['workbench'],
},
{
id: 'generate',
labelKey: 'shell.tabGenerate',
icon: 'image',
href: '/image',
covers: ['image', 'video'],
},
];
/**
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
* is still its own route, so it is listed there too until the two
* generation pages merge.
*/
export const MORE_ITEMS: NavItem[] = NAV.filter(
(item) => !TABS.some((tab) => tab.href === item.href),
);
// Tools awaiting their replacement keep reachable entries in global search.
export const NAV = [
...NAV_MAIN,
...NAV_MANAGE,
...NAV_FOOT,
{ id: "video", label: "视频创作", icon: "film", href: "/video" },
{ id: "models", label: "大语言模型", icon: "assets", href: "/models" },
{
id: "connections",
label: "ComfyUI 连接",
icon: "link",
href: "/connections",
},
{ id: "image", label: "试验台", icon: "playground", href: "/playground" },
{ id: "video", label: "三维搭景", icon: "story", href: "/build-scene" },
] as const;
+27 -26
View File
@@ -1,37 +1,38 @@
<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"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- content is selected only from the bundled icons.ts SVG registry; no API/user strings enter it. HTML preserves the authored SVG child elements. -->
{@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>
@@ -12,6 +12,9 @@
onclose?: () => void;
footer?: Snippet;
children: Snippet;
/** Open above a dock, retaining the shared dialog focus and keyboard behavior. */
anchor?: HTMLElement | null;
headerActions?: Snippet;
}
let {
@@ -22,6 +25,8 @@
onclose,
footer,
children,
anchor = null,
headerActions,
}: Props = $props();
const FOCUSABLE =
@@ -30,6 +35,28 @@
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
let panel = $state<HTMLDivElement | null>(null);
let anchorBox = $state({ left: 0, bottom: 0, width: 620, available: 560 });
$effect(() => {
if (!open || !anchor) return;
const source = anchor;
const place = () => {
const r = source.getBoundingClientRect();
anchorBox = {
left: r.left + 12,
bottom: innerHeight - r.top + 8,
width: r.width - 24,
available: Math.max(100, r.top - 64),
};
};
place();
const observer = new ResizeObserver(place);
observer.observe(source);
window.addEventListener('resize', place);
return () => {
observer.disconnect();
window.removeEventListener('resize', place);
};
});
const layer = Symbol('dialog');
function requestClose() {
@@ -100,10 +127,20 @@
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div
class="modal-backdrop"
class:anchored={!!anchor}
onclick={handleBackdropClick}
>
<div
bind:this={panel}
class="modal-panel size-{size}"
style:left={anchor ? `${anchorBox.left}px` : undefined}
style:bottom={anchor ? `${anchorBox.bottom}px` : undefined}
style:width={anchor
? `min(var(--desk-pop-width), ${anchorBox.width}px)`
: undefined}
style:--anchor-available={`${anchorBox.available}px`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
@@ -115,6 +152,7 @@
{#if title}
<h2 class="modal-title" id={titleId}>{title}</h2>
{/if}
{@render headerActions?.()}
{#if dismissible}
<button
type="button"
@@ -168,6 +206,38 @@
.modal-panel.size-xl {
width: min(1180px, 100%);
}
.anchored {
background: transparent;
padding: 0;
}
.anchored .modal-panel {
box-sizing: border-box;
position: absolute;
height: var(--desk-pop-height);
max-height: min(64vh, var(--anchor-available));
border: 1px solid var(--line-2);
border-radius: var(--desk-dock-radius);
background: var(--desk-pop-bg);
box-shadow: var(--desk-pop-shadow);
backdrop-filter: blur(14px);
transform-origin: bottom left;
}
.anchored .modal-header {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.anchored .modal-title {
font-size: var(--fs-body);
}
.anchored .modal-body {
flex: 1;
min-height: 0;
padding: 12px 14px;
}
.anchored .modal-footer {
padding: 10px 14px;
border-top: 1px solid var(--line);
}
.modal-header {
display: flex;
align-items: center;
+18 -16
View File
@@ -92,14 +92,14 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
`import ProgressBar from '$lib/components/ui/ProgressBar.svelte';`
| Prop | Type | Default | Notes |
| --------------- | -------------- | ------- | ----------------------------------------- |
| `value` | `number` | `0` | clamped to 0–100 |
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
| `label` | `string` | — | text above the track, also the aria-label |
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
| `bare` | `boolean` | `false` | track only, no label / % row |
| Prop | Type | Default | Notes |
| --------------- | ---------------------------------- | ---------- | ----------------------------------------- |
| `value` | `number` | `0` | clamped to 0–100 |
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
| `label` | `string` | — | text above the track, also the aria-label |
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
| `bare` | `boolean` | `false` | track only, no label / % row |
```svelte
<ProgressBar value={job.progress} label={job.name} />
@@ -126,14 +126,16 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
`import Modal from '$lib/components/ui/Modal.svelte';`
| Prop | Type | Default | Notes |
| ------------- | ------------ | -------- | --------------------------------------------------------------------------- |
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
| `footer` | `Snippet` | — | right-aligned action row |
| `children` | `Snippet` | required | body content |
| Prop | Type | Default | Notes |
| --------------- | --------------------- | -------- | -------------------------------------------------------------------------------------------- |
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
| `anchor` | `HTMLElement \| null` | `null` | Optional dock anchor: opens above it, clamps to viewport, retains the dialog focus behavior. |
| `headerActions` | `Snippet` | — | Optional controls beside the title (for example restore workflow defaults). |
| `footer` | `Snippet` | — | right-aligned action row |
| `children` | `Snippet` | required | body content |
Behavior: Esc and backdrop click close when dismissible; focus is trapped while
open; initial focus goes to the first focusable element (or the panel); focus is
+97 -70
View File
@@ -4,76 +4,103 @@
* 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"/>',
// Approved HEL-726 rail geometry; existing tool icons retain their contracts.
"nav-home":
'<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>',
"nav-project":
'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 4v5M13 4v5"/>',
"nav-create":
'<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/><circle cx="12" cy="12" r="3"/>',
"nav-agent":
'<path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z"/>',
"nav-canvas":
'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/>',
"nav-lib":
'<rect x="3" y="5" width="18" height="15" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/>',
"nav-queue":
'<path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/>',
"nav-flow":
'<rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/><path d="M9 6h4a2 2 0 0 1 2 2v7"/>',
"nav-plug": '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
"nav-gear":
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
"nav-palette":
'<path d="M12 3a9 9 0 1 0 0 18c1 0 1.5-.8 1.2-1.6-.4-1 .3-2.4 1.6-2.4H17a4 4 0 0 0 4-4c0-5.5-4-10-9-10z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/>',
// 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;
@@ -1,17 +1,17 @@
// Only the uppermost dialog handles the keyboard. Nested dialogs keep the
// original body overflow until the final layer closes.
const layers: symbol[] = [];
let bodyOverflow = '';
let bodyOverflow = "";
export function enterModal(token: symbol) {
if (!layers.length) bodyOverflow = document.body.style.overflow;
layers.push(token);
document.body.style.overflow = 'hidden';
if (!layers.length) bodyOverflow = document.body.style.overflow;
layers.push(token);
document.body.style.overflow = "hidden";
}
export function topModal(token: symbol) {
return layers.at(-1) === token;
return layers.at(-1) === token;
}
export function leaveModal(token: symbol) {
const index = layers.indexOf(token);
if (index >= 0) layers.splice(index, 1);
document.body.style.overflow = layers.length ? 'hidden' : bodyOverflow;
const index = layers.indexOf(token);
if (index >= 0) layers.splice(index, 1);
document.body.style.overflow = layers.length ? "hidden" : bodyOverflow;
}
@@ -111,7 +111,7 @@ let {
</div>
{#if previewUrl}
<div class="foot">
<a class="dl" href={previewUrl} download>
<a class="dl" href={previewUrl} download rel="external">
<Icon name="download" size={14} /> {t('clipMonitor.downloadClip')}
</a>
</div>
@@ -98,7 +98,6 @@
>
<div class="thumb">
{#if opt.path}
<!-- svelte-ignore a11y_media_has_caption -->
<video
src={assetUrl(opt.path)}
muted
@@ -11,7 +11,7 @@
import Button from '$lib/components/ui/Button.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import '$lib/components/SafeMedia.svelte';
import { assetUrl } from '$lib/api';
import { t } from '$lib/i18n.svelte';
@@ -100,7 +100,6 @@ title={t('filmstrip.clipTitle', {
{#if thumb?.kind === 'image'}
<img class="thumb-media" src={thumb.src} alt="" loading="lazy" />
{:else if thumb?.kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="thumb-media" src={thumb.src + '#t=0.1'} muted playsinline preload="metadata"></video>
{:else}
<span class="thumb-slate">{entry.label}</span>
@@ -66,7 +66,7 @@
<div class="block">
<span class="k">{t('scriptDrawer.characters')}</span>
<div class="chips">
{#each scene.characters as c}
{#each scene.characters as c (c.id)}
<span class="chip">{c.name}</span>
{/each}
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
/**
* VideoEditWorkspace — modern Edit layout for Project → Video.
* Hero monitor + filmstrip + meta strip + docked Omni composer.
@@ -11,7 +12,7 @@
import ClipMonitor from './ClipMonitor.svelte';
import ClipSourceModal from './ClipSourceModal.svelte';
import JobInputsDrawer from './JobInputsDrawer.svelte';
import PromptPreviewModal from './PromptPreviewModal.svelte';
import './PromptPreviewModal.svelte';
import SceneFilmstrip, { type FilmstripClip } from './SceneFilmstrip.svelte';
import SceneScriptDrawer from './SceneScriptDrawer.svelte';
import { t } from '$lib/i18n.svelte';
@@ -87,7 +88,6 @@ import { t } from '$lib/i18n.svelte';
}
let {
scenes,
filmClips,
selectedClip,
selectedClipId,
@@ -266,7 +266,7 @@ import { t } from '$lib/i18n.svelte';
<div class="no-wf">
<p class="empty-title">{t('videoEdit.noWf')}</p>
<p class="muted">
{t('videoEdit.enableWfPre')} <a href="/settings?tab=workflows">{t('videoEdit.wfLink')}</a>{t('videoEdit.enableWfPost')}
{t('videoEdit.enableWfPre')} <a href={resolve('/settings?tab=workflows')}>{t('videoEdit.wfLink')}</a>{t('videoEdit.enableWfPost')}
</p>
</div>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { WorkbenchAsset, WorkbenchStage } from '$lib/api';
import { assetUrl } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
@@ -162,7 +163,7 @@
</div>
{#if asset.description}<p class="asset-desc">{asset.description}</p>{/if}
{#if asset.job?.status === 'done' && asset.job.output}
<a class="asset-shot" href={assetUrl(asset.job.output) ?? '#'} target="_blank" rel="noopener">
<a class="asset-shot" href={assetUrl(asset.job.output) ?? '#'} target="_blank" rel="noopener external">
<img src={assetUrl(asset.job.output) ?? ''} alt={asset.name} loading="lazy" />
</a>
{/if}
@@ -174,7 +175,7 @@
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
href={resolve(deskHref({
kind: 'image',
projectId,
stage: 'assets',
@@ -186,7 +187,7 @@
negative: asset.negative_prompt || undefined,
sourceKind: 'asset',
sourceId: asset.id,
})}
}))}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { VideoShot, WorkbenchStage } from '$lib/api';
import { assetUrl } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
@@ -153,6 +154,7 @@
</div>
{#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
{#if shot.job?.status === 'done' && shot.job.output}
<!-- svelte-ignore a11y_media_has_caption (生成产物暂无字幕资产,保留声音与手动播放;字幕无障碍欠缺仍存在。) -->
<video
class="vshot-video"
src={assetUrl(shot.job.output) ?? undefined}
@@ -165,7 +167,7 @@
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
href={resolve(deskHref({
kind: 'video',
projectId,
stage: 'shot',
@@ -174,7 +176,7 @@
durationSec: shot.duration_sec,
sourceKind: 'shot',
sourceId: shot.id,
})}
}))}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -52,7 +52,7 @@
</script>
<ol class="stepper" data-testid="workbench-stepper">
{#each ORDER as key, i}
{#each ORDER as key, i (key)}
{@const stage = stages.find((s) => s.stage_key === key)}
{@const state = stage ? stateOf(stage) : 'todo'}
{@const open = stage ? unlocked(stages, key) : i === 0}
@@ -198,6 +198,7 @@
function toggleIssue(f: WorkflowConvertFileReport) {
const key = rk(f);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Mutate an isolated copy then assign expandedIssues once; rendering observes the state replacement, not intermediate Set writes.
const next = new Set(expandedIssues);
if (next.has(key)) next.delete(key);
else next.add(key);
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/** Preserve the exact query/hash while keeping the destination typed as /create. */
export function createHref(
url: Pick<URL, "search" | "hash">,
): "/create" | `/create?${string}` | `/create#${string}` {
if (url.search) return `/create?${url.search.slice(1)}${url.hash}`;
if (url.hash) return `/create#${url.hash.slice(1)}`;
return "/create";
}
+4 -4
View File
@@ -5,8 +5,8 @@
* that already carry a timezone marker pass through unchanged.
*/
export function parseDbTs(raw: string | null | undefined): number {
if (!raw) return NaN;
const s = raw.trim();
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
return Date.parse(s.endsWith('Z') ? s : `${s.replace(' ', 'T')}Z`);
if (!raw) return NaN;
const s = raw.trim();
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
return Date.parse(s.endsWith("Z") ? s : `${s.replace(" ", "T")}Z`);
}
+54 -47
View File
@@ -3,71 +3,78 @@
* Used so Story UI can show the beat budget before drafting.
*/
export function estimateTargetSeconds(targetDuration: string | null | undefined): number {
const text = (targetDuration || '').trim().toLowerCase();
if (!text) return 30;
export function estimateTargetSeconds(
targetDuration: string | null | undefined,
): number {
const text = (targetDuration || "").trim().toLowerCase();
if (!text) return 30;
const scenesM = text.match(/(\d+)\s*scenes?/);
if (scenesM && !/min|sec/.test(text)) {
return Math.max(30, Number(scenesM[1]) * 8);
}
const scenesM = text.match(/(\d+)\s*scenes?/);
if (scenesM && !/min|sec/.test(text)) {
return Math.max(30, Number(scenesM[1]) * 8);
}
let minutes = 0;
let seconds = 0;
for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*(min|mins|minutes?|m)\b/g)) {
minutes += Number(m[1]);
}
for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*(sec|secs|seconds?|s)\b/g)) {
seconds += Number(m[1]);
}
if (minutes || seconds) return Math.max(15, Math.round(minutes * 60 + seconds));
let minutes = 0;
let seconds = 0;
for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*(min|mins|minutes?|m)\b/g)) {
minutes += Number(m[1]);
}
for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*(sec|secs|seconds?|s)\b/g)) {
seconds += Number(m[1]);
}
if (minutes || seconds)
return Math.max(15, Math.round(minutes * 60 + seconds));
const bare = text.match(/\b(\d+(?:\.\d+)?)\b/);
if (bare) {
const n = Number(bare[1]);
if (text.includes('medium') || n >= 2) {
if (n <= 30) return Math.round(n * 60);
}
if (n <= 180) return Math.round(n);
}
const bare = text.match(/\b(\d+(?:\.\d+)?)\b/);
if (bare) {
const n = Number(bare[1]);
if (text.includes("medium") || n >= 2) {
if (n <= 30) return Math.round(n * 60);
}
if (n <= 180) return Math.round(n);
}
if (text.includes('medium')) return 120;
if (text.includes('short') || text.includes('brief')) return 30;
if (text.includes('long') || text.includes('feature')) return 180;
return 60;
if (text.includes("medium")) return 120;
if (text.includes("short") || text.includes("brief")) return 30;
if (text.includes("long") || text.includes("feature")) return 180;
return 60;
}
/** ~12s narrative weight per beat; capped at 60. */
export function recommendBeatCount(targetDuration: string | null | undefined): number {
const secs = estimateTargetSeconds(targetDuration);
return Math.max(4, Math.min(60, Math.round(secs / 12)));
export function recommendBeatCount(
targetDuration: string | null | undefined,
): number {
const secs = estimateTargetSeconds(targetDuration);
return Math.max(4, Math.min(60, Math.round(secs / 12)));
}
export function recommendSceneCount(targetDuration: string | null | undefined): number {
const secs = estimateTargetSeconds(targetDuration);
return Math.max(4, Math.min(90, Math.round(secs / 7)));
export function recommendSceneCount(
targetDuration: string | null | undefined,
): number {
const secs = estimateTargetSeconds(targetDuration);
return Math.max(4, Math.min(90, Math.round(secs / 7)));
}
/** Same user prompt the backend sends to the LLM (for transparency). */
export function buildStoryDraftPromptPreview(args: {
title: string;
idea: string;
genre: string;
tone: string;
targetDuration: string;
title: string;
idea: string;
genre: string;
tone: string;
targetDuration: string;
}): string {
const secs = estimateTargetSeconds(args.targetDuration);
const beatN = recommendBeatCount(args.targetDuration);
return `Create a story brief for an AI-generated video.
const secs = estimateTargetSeconds(args.targetDuration);
const beatN = recommendBeatCount(args.targetDuration);
return `Create a story brief for an AI-generated video.
required_beat_count: ${beatN}
estimated_runtime_seconds: ${secs}
target_duration_text: ${args.targetDuration || 'short (~30 seconds)'}
target_duration_text: ${args.targetDuration || "short (~30 seconds)"}
Title: ${args.title || 'Untitled'}
Idea: ${args.idea || 'No idea provided.'}
Genre: ${args.genre || 'Not specified'}
Tone: ${args.tone || 'cinematic, atmospheric'}
Title: ${args.title || "Untitled"}
Idea: ${args.idea || "No idea provided."}
Genre: ${args.genre || "Not specified"}
Tone: ${args.tone || "cinematic, atmospheric"}
=== HARD CONSTRAINTS (non-negotiable) ===
1. The JSON field "beats" MUST contain EXACTLY ${beatN} objects.
+36 -25
View File
@@ -1,33 +1,44 @@
import { t } from '$lib/i18n.svelte';
import { t } from "$lib/i18n.svelte";
export type EndpointKind = 'local' | 'lan' | 'cloud';
export type EndpointKind = "local" | "lan" | "cloud";
/** 本机 / 局域网 / 云端 by host — drives the blue (local, LAN) vs purple (cloud) tags (§2.1). */
export function classifyEndpoint(rawUrl: string): EndpointKind {
try {
const u = new URL(rawUrl.trim());
const h = u.hostname.toLowerCase();
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return 'local';
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (m) {
const a = Number(m[1]);
const b = Number(m[2]);
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return 'lan';
if (a === 169 && b === 254) return 'lan';
return 'cloud';
}
if (!h.includes('.') || h.endsWith('.local') || h.endsWith('.lan') || h.endsWith('.internal'))
return 'lan';
return 'cloud';
} catch {
return 'cloud';
}
try {
const u = new URL(rawUrl.trim());
const h = u.hostname.toLowerCase();
if (h === "localhost" || h === "::1" || h.startsWith("127."))
return "local";
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (m) {
const a = Number(m[1]);
const b = Number(m[2]);
if (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
)
return "lan";
if (a === 169 && b === 254) return "lan";
return "cloud";
}
if (
!h.includes(".") ||
h.endsWith(".local") ||
h.endsWith(".lan") ||
h.endsWith(".internal")
)
return "lan";
return "cloud";
} catch {
return "cloud";
}
}
export function endpointTypeLabel(kind: EndpointKind): string {
return kind === 'local'
? t('models.typeLocal')
: kind === 'lan'
? t('models.typeLan')
: t('models.typeCloud');
return kind === "local"
? t("models.typeLocal")
: kind === "lan"
? t("models.typeLan")
: t("models.typeCloud");
}
+74 -70
View File
@@ -1,7 +1,7 @@
export type CalliopeEvent = {
type: string;
data: Record<string, unknown>;
ts: string;
type: string;
data: Record<string, unknown>;
ts: string;
};
/**
@@ -10,80 +10,84 @@ export type CalliopeEvent = {
* - `open` — stream established (EventSource.onopen fired)
* - `reconnecting` — stream dropped; the 3s re-attach loop is running
*/
export type EventConnectionState = 'connecting' | 'open' | 'reconnecting';
export type EventConnectionState = "connecting" | "open" | "reconnecting";
type Listener = (event: CalliopeEvent) => void;
type StateListener = (state: EventConnectionState) => void;
export function connectEvents(
onEvent: Listener,
onState?: StateListener,
onEvent: Listener,
onState?: StateListener,
): (signal?: AbortSignal) => void {
let es: EventSource | null = null;
let closed = false;
let firstAttach = true;
let everOpened = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let es: EventSource | null = null;
let closed = false;
let firstAttach = true;
let everOpened = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const attach = () => {
if (closed) return;
onState?.(firstAttach ? 'connecting' : 'reconnecting');
firstAttach = false;
es = new EventSource('/api/events');
es.onopen = () => {
onState?.('open');
// Reconnected after a drop: everything missed while the stream was
// down is gone forever (no replay), so ask the page to refetch.
// Listeners gate on `everOpened` so the very first open is a no-op.
if (everOpened) {
onEvent({ type: 'events.resync', data: {}, ts: new Date().toISOString() });
}
everOpened = true;
};
const handler = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as CalliopeEvent;
onEvent(parsed);
} catch {
/* ignore */
}
};
// Listen to named events + default message
[
'agent.thinking',
'agent.session.updated',
'agent.message',
'agent.token',
'agent.tool',
'agent.plan',
'agent.task',
'job.created',
'job.started',
'job.progress',
'job.completed',
'job.failed',
'job.deleted',
'asset.ready',
'story.ready',
'canvas.updated',
'shot.updated',
'shot.capture.saved',
].forEach((name) => es!.addEventListener(name, handler as EventListener));
es.onmessage = handler;
es.onerror = () => {
onState?.('reconnecting');
es?.close();
if (!closed) {
reconnectTimer = setTimeout(attach, 3000);
}
};
};
const attach = () => {
if (closed) return;
onState?.(firstAttach ? "connecting" : "reconnecting");
firstAttach = false;
es = new EventSource("/api/events");
es.onopen = () => {
onState?.("open");
// Reconnected after a drop: everything missed while the stream was
// down is gone forever (no replay), so ask the page to refetch.
// Listeners gate on `everOpened` so the very first open is a no-op.
if (everOpened) {
onEvent({
type: "events.resync",
data: {},
ts: new Date().toISOString(),
});
}
everOpened = true;
};
const handler = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as CalliopeEvent;
onEvent(parsed);
} catch {
/* ignore */
}
};
// Listen to named events + default message
[
"agent.thinking",
"agent.session.updated",
"agent.message",
"agent.token",
"agent.tool",
"agent.plan",
"agent.task",
"job.created",
"job.started",
"job.progress",
"job.completed",
"job.failed",
"job.deleted",
"asset.ready",
"story.ready",
"canvas.updated",
"shot.updated",
"shot.capture.saved",
].forEach((name) => es!.addEventListener(name, handler as EventListener));
es.onmessage = handler;
es.onerror = () => {
onState?.("reconnecting");
es?.close();
if (!closed) {
reconnectTimer = setTimeout(attach, 3000);
}
};
};
attach();
attach();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
es?.close();
};
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
es?.close();
};
}
+18 -18
View File
@@ -1,26 +1,26 @@
// Maps backend-stored English values to i18n keys for display labels.
export const GENRE_KEYS: Record<string, string> = {
'Adventure / Mystery': 'genres.adventure',
Drama: 'genres.drama',
'Sci-Fi': 'genres.scifi',
Fantasy: 'genres.fantasy',
Horror: 'genres.horror',
Romance: 'genres.romance',
Thriller: 'genres.thriller',
"Adventure / Mystery": "genres.adventure",
Drama: "genres.drama",
"Sci-Fi": "genres.scifi",
Fantasy: "genres.fantasy",
Horror: "genres.horror",
Romance: "genres.romance",
Thriller: "genres.thriller",
};
export const TONE_KEYS: Record<string, string> = {
'Cinematic, atmospheric': 'tones.cinematic',
'Dark, tense': 'tones.dark',
'Whimsical, warm': 'tones.whimsical',
'Gritty, realistic': 'tones.gritty',
'Epic, sweeping': 'tones.epic',
"Cinematic, atmospheric": "tones.cinematic",
"Dark, tense": "tones.dark",
"Whimsical, warm": "tones.whimsical",
"Gritty, realistic": "tones.gritty",
"Epic, sweeping": "tones.epic",
};
export const DURATION_KEYS: Record<string, string> = {
'30 seconds': 'durations.30',
'1 minute': 'durations.1m',
'2 minutes': 'durations.2m',
'5 minutes': 'durations.5m',
'10 minutes': 'durations.10m',
};
"30 seconds": "durations.30",
"1 minute": "durations.1m",
"2 minutes": "durations.2m",
"5 minutes": "durations.5m",
"10 minutes": "durations.10m",
};
+42 -28
View File
@@ -1,47 +1,61 @@
import { de } from './i18n/de';
import { en } from './i18n/en';
import { es } from './i18n/es';
import { fr } from './i18n/fr';
import { ja } from './i18n/ja';
import { ko } from './i18n/ko';
import { zh } from './i18n/zh';
import { de } from "./i18n/de";
import { en } from "./i18n/en";
import { es } from "./i18n/es";
import { fr } from "./i18n/fr";
import { ja } from "./i18n/ja";
import { ko } from "./i18n/ko";
import { zh } from "./i18n/zh";
import { storageGet, storageSet } from './storage';
import { storageGet, storageSet } from "./storage";
export type Language = 'en' | 'zh' | 'es' | 'fr' | 'de' | 'ja' | 'ko';
export type Language = "en" | "zh" | "es" | "fr" | "de" | "ja" | "ko";
export type Dict = typeof en;
const dictionaries: Record<Language, Record<string, string>> = { en, zh, es, fr, de, ja, ko };
const dictionaries: Record<Language, Record<string, string>> = {
en,
zh,
es,
fr,
de,
ja,
ko,
};
const STORAGE_KEY = 'calliope-lang';
const STORAGE_KEY = "calliope-lang";
function isLanguage(value: string | null): value is Language {
return value !== null && Object.hasOwn(dictionaries, value);
return value !== null && Object.hasOwn(dictionaries, value);
}
function initialLanguage(): Language {
// Validated against the registered dictionaries — a stored value naming a
// language this build no longer ships must not blank the UI. This runs on
// the server too, where storage is absent and we render the default.
const stored = storageGet(STORAGE_KEY);
return isLanguage(stored) ? stored : 'zh';
// Validated against the registered dictionaries — a stored value naming a
// language this build no longer ships must not blank the UI. This runs on
// the server too, where storage is absent and we render the default.
const stored = storageGet(STORAGE_KEY);
return isLanguage(stored) ? stored : "zh";
}
export const language = $state<{ current: Language }>({ current: initialLanguage() });
export const language = $state<{ current: Language }>({
current: initialLanguage(),
});
export function setLanguage(lang: Language) {
language.current = lang;
storageSet(STORAGE_KEY, lang);
language.current = lang;
storageSet(STORAGE_KEY, lang);
}
/** Look up a UI string. Falls back to English, then to the key itself. */
export function t(key: string, vars?: Record<string, string | number>): string {
let s: string = dictionaries[language.current][key] ?? (en as Record<string, string>)[key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
// ponytail: count 语义占位统一为 {n};调用方传 count 即命中
if (vars.count !== undefined) s = s.replaceAll('{n}', String(vars.count));
}
return s;
}
let s: string =
dictionaries[language.current][key] ??
(en as Record<string, string>)[key] ??
key;
if (vars) {
for (const [k, v] of Object.entries(vars))
s = s.replaceAll(`{${k}}`, String(v));
// ponytail: count 语义占位统一为 {n};调用方传 count 即命中
if (vars.count !== undefined) s = s.replaceAll("{n}", String(vars.count));
}
return s;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+150 -144
View File
@@ -1,6 +1,6 @@
import { SvelteMap } from 'svelte/reactivity';
import type { CalliopeEvent } from './events';
import { t } from './i18n.svelte';
import { SvelteMap } from "svelte/reactivity";
import type { CalliopeEvent } from "./events";
import { t } from "./i18n.svelte";
/**
* Live per-job render progress, fed by the `/api/events` SSE stream.
@@ -28,20 +28,20 @@ import { t } from './i18n.svelte';
*/
export type JobProgressEntry = {
/** 0–100. Reaches 100 only via job.completed. */
progress: number;
/** Latest human-readable status line from the worker. */
message?: string;
/** Worker phase from job.progress (comfy_queued / comfy_running …). */
phase?: string;
/** Prompts ahead of this one in ComfyUI's own queue (comfy_queued only). */
queueAhead?: number | null;
/** True once the job reached a terminal state (done/failed). */
final: boolean;
/** Terminal status when final: 'done' | 'failed'. */
status?: 'done' | 'failed';
/** Last update, ms epoch. */
updatedAt: number;
/** 0–100. Reaches 100 only via job.completed. */
progress: number;
/** Latest human-readable status line from the worker. */
message?: string;
/** Worker phase from job.progress (comfy_queued / comfy_running …). */
phase?: string;
/** Prompts ahead of this one in ComfyUI's own queue (comfy_queued only). */
queueAhead?: number | null;
/** True once the job reached a terminal state (done/failed). */
final: boolean;
/** Terminal status when final: 'done' | 'failed'. */
status?: "done" | "failed";
/** Last update, ms epoch. */
updatedAt: number;
};
/** Synthesized ticks never exceed this; only job.completed reports 100. */
@@ -51,156 +51,162 @@ const DONE_KEEP_MS = 6_000;
const FAILED_KEEP_MS = 10_000;
function num(v: unknown): number | null {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
return null;
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)))
return Number(v);
return null;
}
function str(v: unknown): string {
return typeof v === 'string' ? v : '';
return typeof v === "string" ? v : "";
}
/** Numeric progress from a payload, normalized to 0–100 (capped below 100). */
function progressValue(d: Record<string, unknown>): number | null {
const raw = num(d.progress ?? d.percent ?? d.value);
if (raw == null) return null;
const pct = raw <= 1 ? raw * 100 : raw;
return Math.min(TICK_CAP, Math.max(0, pct));
const raw = num(d.progress ?? d.percent ?? d.value);
if (raw == null) return null;
const pct = raw <= 1 ? raw * 100 : raw;
return Math.min(TICK_CAP, Math.max(0, pct));
}
class JobProgressStore {
private map = new SvelteMap<number, JobProgressEntry>();
/** Jobs seen via job.started and not yet terminal — tick attribution. */
private running = new Set<number>();
private lastStarted: number | null = null;
private cleanupTimers = new Map<number, ReturnType<typeof setTimeout>>();
private map = new SvelteMap<number, JobProgressEntry>();
/** Jobs seen via job.started and not yet terminal — tick attribution. */
private running = new Set<number>();
private lastStarted: number | null = null;
private cleanupTimers = new Map<number, ReturnType<typeof setTimeout>>();
/** Live entry for a job id (tracked — safe inside $derived / markup). */
get(jobId: number | null | undefined): JobProgressEntry | undefined {
if (jobId == null) return undefined;
return this.map.get(jobId);
}
/** Live entry for a job id (tracked — safe inside $derived / markup). */
get(jobId: number | null | undefined): JobProgressEntry | undefined {
if (jobId == null) return undefined;
return this.map.get(jobId);
}
handleEvent(ev: CalliopeEvent): void {
const d = ev.data ?? {};
switch (ev.type) {
case 'job.created': {
const id = num(d.job_id);
if (id == null) return;
this.cancelCleanup(id);
this.map.set(id, {
progress: 0,
message: str(d.message) || t('common.queued'),
final: false,
updatedAt: Date.now(),
});
break;
}
case 'job.started': {
const id = num(d.job_id);
if (id == null) return;
this.cancelCleanup(id);
this.running.add(id);
this.lastStarted = id;
const prev = this.map.get(id);
this.map.set(id, {
progress: prev?.progress ?? 0,
message: str(d.message) || t('common.running'),
final: false,
updatedAt: Date.now(),
});
break;
}
case 'job.progress': {
const id = this.resolveJobId(d);
if (id == null) return;
const prev = this.map.get(id);
const progress = progressValue(d) ?? this.synthesize(prev?.progress ?? 0);
this.map.set(id, {
progress,
message: str(d.message) || prev?.message,
phase: str(d.phase) || prev?.phase,
queueAhead: d.queue_ahead === undefined ? prev?.queueAhead : num(d.queue_ahead),
final: false,
updatedAt: Date.now(),
});
break;
}
case 'job.completed':
case 'job.failed': {
const id = num(d.job_id);
if (id == null) return;
this.running.delete(id);
if (this.lastStarted === id) this.lastStarted = null;
const failed = ev.type === 'job.failed';
const prev = this.map.get(id);
this.map.set(id, {
progress: failed ? (prev?.progress ?? 0) : 100,
message: str(d.error) || str(d.message) || prev?.message,
final: true,
status: failed ? 'failed' : 'done',
updatedAt: Date.now(),
});
this.scheduleCleanup(id, failed ? FAILED_KEEP_MS : DONE_KEEP_MS);
break;
}
case 'job.deleted': {
const id = num(d.job_id);
if (id == null) return;
this.running.delete(id);
if (this.lastStarted === id) this.lastStarted = null;
this.cancelCleanup(id);
this.map.delete(id);
break;
}
}
}
handleEvent(ev: CalliopeEvent): void {
const d = ev.data ?? {};
switch (ev.type) {
case "job.created": {
const id = num(d.job_id);
if (id == null) return;
this.cancelCleanup(id);
this.map.set(id, {
progress: 0,
message: str(d.message) || t("common.queued"),
final: false,
updatedAt: Date.now(),
});
break;
}
case "job.started": {
const id = num(d.job_id);
if (id == null) return;
this.cancelCleanup(id);
this.running.add(id);
this.lastStarted = id;
const prev = this.map.get(id);
this.map.set(id, {
progress: prev?.progress ?? 0,
message: str(d.message) || t("common.running"),
final: false,
updatedAt: Date.now(),
});
break;
}
case "job.progress": {
const id = this.resolveJobId(d);
if (id == null) return;
const prev = this.map.get(id);
const progress =
progressValue(d) ?? this.synthesize(prev?.progress ?? 0);
this.map.set(id, {
progress,
message: str(d.message) || prev?.message,
phase: str(d.phase) || prev?.phase,
queueAhead:
d.queue_ahead === undefined ? prev?.queueAhead : num(d.queue_ahead),
final: false,
updatedAt: Date.now(),
});
break;
}
case "job.completed":
case "job.failed": {
const id = num(d.job_id);
if (id == null) return;
this.running.delete(id);
if (this.lastStarted === id) this.lastStarted = null;
const failed = ev.type === "job.failed";
const prev = this.map.get(id);
this.map.set(id, {
progress: failed ? (prev?.progress ?? 0) : 100,
message: str(d.error) || str(d.message) || prev?.message,
final: true,
status: failed ? "failed" : "done",
updatedAt: Date.now(),
});
this.scheduleCleanup(id, failed ? FAILED_KEEP_MS : DONE_KEEP_MS);
break;
}
case "job.deleted": {
const id = num(d.job_id);
if (id == null) return;
this.running.delete(id);
if (this.lastStarted === id) this.lastStarted = null;
this.cancelCleanup(id);
this.map.delete(id);
break;
}
}
}
/**
* Explicit job_id wins; a tick without one goes to the only running job,
* else the most recently started one.
*/
private resolveJobId(d: Record<string, unknown>): number | null {
const explicit = num(d.job_id);
if (explicit != null) return explicit;
if (this.running.size === 1) return [...this.running][0];
if (this.lastStarted != null && this.running.has(this.lastStarted)) return this.lastStarted;
return null;
}
/**
* Explicit job_id wins; a tick without one goes to the only running job,
* else the most recently started one.
*/
private resolveJobId(d: Record<string, unknown>): number | null {
const explicit = num(d.job_id);
if (explicit != null) return explicit;
if (this.running.size === 1) return [...this.running][0];
if (this.lastStarted != null && this.running.has(this.lastStarted))
return this.lastStarted;
return null;
}
/** Asymptotic approach to TICK_CAP — feels live, never falsely completes. */
private synthesize(prev: number): number {
return prev + (TICK_CAP - prev) * 0.08;
}
/** Asymptotic approach to TICK_CAP — feels live, never falsely completes. */
private synthesize(prev: number): number {
return prev + (TICK_CAP - prev) * 0.08;
}
private scheduleCleanup(jobId: number, delayMs: number): void {
this.cancelCleanup(jobId);
this.cleanupTimers.set(
jobId,
setTimeout(() => {
this.cleanupTimers.delete(jobId);
this.map.delete(jobId);
}, delayMs),
);
}
private scheduleCleanup(jobId: number, delayMs: number): void {
this.cancelCleanup(jobId);
this.cleanupTimers.set(
jobId,
setTimeout(() => {
this.cleanupTimers.delete(jobId);
this.map.delete(jobId);
}, delayMs),
);
}
private cancelCleanup(jobId: number): void {
const timer = this.cleanupTimers.get(jobId);
if (timer) {
clearTimeout(timer);
this.cleanupTimers.delete(jobId);
}
}
private cancelCleanup(jobId: number): void {
const timer = this.cleanupTimers.get(jobId);
if (timer) {
clearTimeout(timer);
this.cleanupTimers.delete(jobId);
}
}
}
export const jobProgress = new JobProgressStore();
/** Feed one SSE event into the store. Safe to call for every event type. */
export function handleJobEvent(ev: CalliopeEvent): void {
jobProgress.handleEvent(ev);
jobProgress.handleEvent(ev);
}
/** Read the live entry for a job id (reactive via SvelteMap). */
export function progressFor(jobId: number | null | undefined): JobProgressEntry | undefined {
return jobProgress.get(jobId);
export function progressFor(
jobId: number | null | undefined,
): JobProgressEntry | undefined {
return jobProgress.get(jobId);
}
+77 -42
View File
@@ -1,15 +1,15 @@
import type { Job } from './comfy/types';
import { progressFor } from './jobProgress';
import { t } from './i18n.svelte';
import type { Job } from "./comfy/types";
import { progressFor } from "./jobProgress";
import { t } from "./i18n.svelte";
export type JobStatusTone = 'ok' | 'run' | 'queue' | 'fail';
export type JobStatusTone = "ok" | "run" | "queue" | "fail";
export interface JobStatusInfo {
tone: JobStatusTone;
/** Short label for status columns. */
label: string;
/** Full sentence for the task card / tooltip. */
detail: string;
tone: JobStatusTone;
/** Short label for status columns. */
label: string;
/** Full sentence for the task card / tooltip. */
detail: string;
}
/**
@@ -17,37 +17,72 @@ export interface JobStatusInfo {
* A dry-run placeholder or a done row without outputs never reads as done.
*/
export function jobStatusInfo(job: Job): JobStatusInfo {
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase ?? null;
const idSuffix = job.comfy_prompt_id ? t('jobst.idSuffix', { id: job.comfy_prompt_id }) : '';
switch (job.status) {
case 'done':
if (phase === 'dry_run')
return { tone: 'queue', label: t('jobst.dryRun'), detail: t('jobst.dryRunDetail') };
if (!(job.output_paths?.length ?? 0))
return { tone: 'fail', label: t('jobst.noOutput'), detail: t('jobst.noOutputDetail') };
return { tone: 'ok', label: t('jobst.done'), detail: t('jobst.doneDetail') + idSuffix };
case 'pending':
return { tone: 'queue', label: t('jobst.queued'), detail: t('jobst.queuedDetail') };
case 'running':
if (phase === 'comfy_queued') {
const ahead = live?.queueAhead;
const detail =
ahead != null && ahead > 0
? t('jobst.comfyQueuedAhead', { n: ahead })
: t('jobst.comfyQueuedDetail');
return { tone: 'queue', label: t('jobst.comfyQueued'), detail: detail + idSuffix };
}
if (phase === 'comfy_running')
return { tone: 'run', label: t('jobst.comfyRunning'), detail: t('jobst.comfyRunningDetail') + idSuffix };
if (phase === 'saving')
return { tone: 'run', label: t('jobst.saving'), detail: t('jobst.savingDetail') + idSuffix };
return { tone: 'run', label: t('jobst.connecting'), detail: t('jobst.connectingDetail') };
default:
return {
tone: 'fail',
label: job.error === 'cancelled' ? t('jobst.cancelled') : t('jobst.failed'),
detail: job.error ?? t('jobst.failed'),
};
}
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase ?? null;
const idSuffix = job.comfy_prompt_id
? t("jobst.idSuffix", { id: job.comfy_prompt_id })
: "";
switch (job.status) {
case "done":
if (phase === "dry_run")
return {
tone: "queue",
label: t("jobst.dryRun"),
detail: t("jobst.dryRunDetail"),
};
if (!(job.output_paths?.length ?? 0))
return {
tone: "fail",
label: t("jobst.noOutput"),
detail: t("jobst.noOutputDetail"),
};
return {
tone: "ok",
label: t("jobst.done"),
detail: t("jobst.doneDetail") + idSuffix,
};
case "pending":
return {
tone: "queue",
label: t("jobst.queued"),
detail: t("jobst.queuedDetail"),
};
case "running":
if (phase === "comfy_queued") {
const ahead = live?.queueAhead;
const detail =
ahead != null && ahead > 0
? t("jobst.comfyQueuedAhead", { n: ahead })
: t("jobst.comfyQueuedDetail");
return {
tone: "queue",
label: t("jobst.comfyQueued"),
detail: detail + idSuffix,
};
}
if (phase === "comfy_running")
return {
tone: "run",
label: t("jobst.comfyRunning"),
detail: t("jobst.comfyRunningDetail") + idSuffix,
};
if (phase === "saving")
return {
tone: "run",
label: t("jobst.saving"),
detail: t("jobst.savingDetail") + idSuffix,
};
return {
tone: "run",
label: t("jobst.connecting"),
detail: t("jobst.connectingDetail"),
};
default:
return {
tone: "fail",
label:
job.error === "cancelled" ? t("jobst.cancelled") : t("jobst.failed"),
detail: job.error ?? t("jobst.failed"),
};
}
}
@@ -62,6 +62,7 @@ export function videoBaseName(src: string): string {
name = "";
}
name = name.replace(/\.[a-z0-9]{1,5}$/i, "");
// eslint-disable-next-line no-control-regex -- U+0000–001F are intentionally rejected in filenames. Keep one underscore per contiguous invalid run, including mixed punctuation/control characters.
name = name.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "_").trim();
return name || "video";
}
+19 -19
View File
@@ -1,38 +1,38 @@
import { SvelteSet } from 'svelte/reactivity';
import type { Job } from '$lib/comfy/types';
import { SvelteSet } from "svelte/reactivity";
import type { Job } from "$lib/comfy/types";
/**
* Output files a done job claims but the asset endpoint could not serve
* (spec-v2 §3.4 红/琥珀界线): the backend task status stays the single
* verdict for red — a FAILED job is red no matter what. Amber means the
* job SUCCEEDED and only its product file is gone (cleaned up, moved,
* storage hiccup). We learn that the only way the frontend can: the
* media element itself failed to load. A later successful load clears
* the mark.
*/
* Output files a done job claims but the asset endpoint could not serve
* (spec-v2 §3.4 红/琥珀界线): the backend task status stays the single
* verdict for red — a FAILED job is red no matter what. Amber means the
* job SUCCEEDED and only its product file is gone (cleaned up, moved,
* storage hiccup). We learn that the only way the frontend can: the
* media element itself failed to load. A later successful load clears
* the mark.
*/
const missing = new SvelteSet<string>();
/** Mark one output path as unreachable (img/video onerror). */
export function markOutputMissing(path: string): void {
if (path) missing.add(path);
if (path) missing.add(path);
}
/** Clear the mark when the same path loads fine after all. */
export function clearOutputMissing(path: string): void {
missing.delete(path);
missing.delete(path);
}
/** Paths of this job's outputs that are currently unreachable. */
export function missingOutputsOf(job: Job | null | undefined): string[] {
if (!job || job.status !== 'done' || !(job.output_paths?.length ?? 0))
return [];
return job.output_paths.filter((p) => missing.has(p));
if (!job || job.status !== "done" || !(job.output_paths?.length ?? 0))
return [];
return job.output_paths.filter((p) => missing.has(p));
}
/**
* The take reads as 文件缺失 when it succeeded but at least one claimed
* product file cannot be fetched.
*/
* The take reads as 文件缺失 when it succeeded but at least one claimed
* product file cannot be fetched.
*/
export function isFileMissing(job: Job | null | undefined): boolean {
return missingOutputsOf(job).length > 0;
return missingOutputsOf(job).length > 0;
}

Some files were not shown because too many files have changed in this diff Show More