Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2004f527cc | ||
|
|
dca88b3765 | ||
|
|
54880e900b | ||
|
|
4c849e9ef9 | ||
|
|
46fd5bbc86 | ||
|
|
fc6616b118 | ||
|
|
22f82fbed6 | ||
|
|
67eb822efe | ||
|
|
99574c2226 | ||
|
|
88965fa70d | ||
|
|
9d54b9d8dd | ||
|
|
a666f6bd12 | ||
|
|
b02c50fdf7 | ||
|
|
7e9a5be19d | ||
|
|
e49b8b0072 | ||
|
|
ddc02fbe4c | ||
|
|
d076978a4a | ||
|
|
4e200cc8ac | ||
|
|
bfcf542e59 | ||
|
|
3d1c4ca5af | ||
|
|
24129438e8 | ||
|
|
155d914df3 | ||
|
|
64c1116bca | ||
|
|
03bebec94a | ||
|
|
0f4d6a8961 | ||
|
|
06c9bd5000 | ||
|
|
acb6beca1c | ||
|
|
a6b04e5c91 | ||
|
|
634965f5f5 | ||
|
|
e62499c517 | ||
|
|
b6b3a9fed3 | ||
|
|
7bf63d0587 | ||
|
|
5cb75d5718 | ||
|
|
2304ab1538 | ||
|
|
8e84e4c21f |
@@ -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 },
|
||||
},
|
||||
];
|
||||
Generated
+4511
-4256
File diff suppressed because it is too large
Load Diff
+39
-37
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export async function handle({ event, resolve }) {
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>    </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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+2469
-2224
File diff suppressed because it is too large
Load Diff
+2425
-2225
File diff suppressed because it is too large
Load Diff
+2459
-2224
File diff suppressed because it is too large
Load Diff
+2476
-2224
File diff suppressed because it is too large
Load Diff
+2444
-2222
File diff suppressed because it is too large
Load Diff
+2416
-2222
File diff suppressed because it is too large
Load Diff
+2333
-2223
File diff suppressed because it is too large
Load Diff
+150
-144
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user