Compare commits
45
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 | ||
|
|
35fd07af24 | ||
|
|
012a946f85 | ||
|
|
875dfcb41a | ||
|
|
780ac52ffc | ||
|
|
d4e199f3c8 | ||
|
|
ae9e34f6ab | ||
|
|
16933b1dfc | ||
|
|
07f6475b1a | ||
|
|
ac1c0f98a6 | ||
|
|
aaddd3dcaf |
@@ -97,7 +97,7 @@ class MemoryCreate(BaseModel):
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
content: str = ""
|
||||
mentions: list[MessageMention] = Field(default_factory=list, max_length=1)
|
||||
mentions: list[MessageMention] = Field(default_factory=list, max_length=5)
|
||||
attachments: list[MessageAttachment] = Field(default_factory=list, max_length=8)
|
||||
answer_to: int | None = None # question/asked seq this message answers (card click)
|
||||
|
||||
@@ -113,8 +113,10 @@ class MessageCreate(BaseModel):
|
||||
@field_validator("mentions")
|
||||
@classmethod
|
||||
def _one_workflow(cls, v: list[MessageMention]) -> list[MessageMention]:
|
||||
if len(v) > 1:
|
||||
if sum(m.type == "workflow" for m in v) > 1:
|
||||
raise ValueError("Only one @workflow mention per message")
|
||||
if sum(m.type == "skill" for m in v) > 4:
|
||||
raise ValueError("At most four skill mentions per message")
|
||||
return v
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ class StagePatch(BaseModel):
|
||||
chosen_style_id: str | None = None
|
||||
|
||||
|
||||
class StageNotePatch(BaseModel):
|
||||
text: str = Field(max_length=200_000)
|
||||
|
||||
|
||||
class StageRunRequest(BaseModel):
|
||||
instruction: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
@@ -292,10 +296,12 @@ def _load_stage(conn, project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
|
||||
content = json.loads(row["content_json"]) if row["content_json"] else None
|
||||
return {
|
||||
"stage_key": row["stage_key"],
|
||||
"status": row["status"],
|
||||
"content": json.loads(row["content_json"]) if row["content_json"] else None,
|
||||
"content": content,
|
||||
"assistant_note": (content or {}).get("assistant_note", ""),
|
||||
"confirmed_content": (
|
||||
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
|
||||
),
|
||||
@@ -571,6 +577,10 @@ async def _execute_run(project_id: int, stage_key: str, run_id: int, instruction
|
||||
|
||||
conn = get_db(db)
|
||||
try:
|
||||
latest_stage = _load_stage(conn, project_id, stage_key)
|
||||
previous = json.loads(latest_stage["content_json"]) if latest_stage["content_json"] else {}
|
||||
if previous.get("assistant_note"):
|
||||
content["assistant_note"] = previous["assistant_note"]
|
||||
conn.execute(
|
||||
"UPDATE stage_runs SET status='done', output_json=?, completed_at=CURRENT_TIMESTAMP "
|
||||
"WHERE id = ?",
|
||||
@@ -681,6 +691,9 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
|
||||
content = NORMALIZERS[stage_key](payload.content)
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
old_content = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if old_content.get("assistant_note"):
|
||||
content["assistant_note"] = old_content["assistant_note"]
|
||||
if content == (json.loads(stage["content_json"]) if stage["content_json"] else None):
|
||||
return {"ok": True, "stage": _stage_public({**stage, "content_json": json.dumps(content, ensure_ascii=False)})}
|
||||
# Editing a confirmed stage produces a new draft awaiting re-confirm;
|
||||
@@ -715,6 +728,32 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}/stages/{stage_key}/note")
|
||||
async def patch_stage_note(project_id: int, stage_key: str, payload: StageNotePatch) -> dict[str, Any]:
|
||||
"""Keep free-text advice separate from validated stage artifacts and snapshots."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
_get_project(conn, project_id)
|
||||
stage = _stage_or_404(conn, project_id, stage_key)
|
||||
if stage["status"] == "running":
|
||||
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候再保存建议。")
|
||||
content = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if payload.text.strip():
|
||||
content["assistant_note"] = payload.text.strip()
|
||||
else:
|
||||
content.pop("assistant_note", None)
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id=? AND stage_key=?",
|
||||
(json.dumps(content, ensure_ascii=False) if content else None, project_id, stage_key),
|
||||
)
|
||||
conn.execute("UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (project_id,))
|
||||
conn.commit()
|
||||
return {"ok": True, "stage": _stage_public(_load_stage(conn, project_id, stage_key))}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/stages/{stage_key}/run")
|
||||
async def run_stage(project_id: int, stage_key: str, payload: StageRunRequest | None = None) -> dict[str, Any]:
|
||||
instruction = (payload.instruction if payload else None) or None
|
||||
@@ -777,7 +816,10 @@ async def confirm_stage(
|
||||
raise HTTPException(status_code=409, detail="本阶段正在生成中,请等待完成。")
|
||||
if not stage["content_json"]:
|
||||
raise HTTPException(status_code=400, detail="本阶段还没有内容,请先生成或手动填写。")
|
||||
content = json.loads(stage["content_json"])
|
||||
try:
|
||||
content = NORMALIZERS[stage_key](json.loads(stage["content_json"]))
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
chosen = payload.chosen_style_id if payload else None
|
||||
if stage_key == "style":
|
||||
directions = content.get("directions") or []
|
||||
@@ -815,11 +857,15 @@ async def restore_stage(project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
stage = _stage_or_404(conn, project_id, stage_key)
|
||||
if not stage["confirmed_content_json"]:
|
||||
raise HTTPException(status_code=400, detail="本阶段还没有已确认版本,无需恢复。")
|
||||
content = json.loads(stage["confirmed_content_json"])
|
||||
current = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if current.get("assistant_note"):
|
||||
content["assistant_note"] = current["assistant_note"]
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=confirmed_content_json, "
|
||||
"UPDATE project_stages SET content_json=?, "
|
||||
"status='confirmed', draft_edited=0, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ?",
|
||||
(project_id, stage_key),
|
||||
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
|
||||
)
|
||||
conn.commit()
|
||||
row = _load_stage(conn, project_id, stage_key)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Workflow uniqueness is independent of the selected task skills."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from calliope.routers.agent import MessageCreate
|
||||
|
||||
|
||||
def workflow(n=1):
|
||||
return {"type": "workflow", "id": n, "name": f"workflow-{n}", "kind": "image"}
|
||||
|
||||
|
||||
def skill(n=1):
|
||||
return {"type": "skill", "name": f"skill-{n}"}
|
||||
|
||||
|
||||
def test_workflow_and_skills_can_be_sent_together():
|
||||
message = MessageCreate(content="Create an image", mentions=[workflow(), *[skill(i) for i in range(4)]])
|
||||
assert len(message.mentions) == 5
|
||||
assert message.mentions[0].id == 1
|
||||
|
||||
|
||||
def test_legacy_single_workflow_is_accepted():
|
||||
assert MessageCreate(mentions=[workflow()]).mentions[0].type == "workflow"
|
||||
|
||||
|
||||
def test_skills_without_workflow_are_accepted():
|
||||
assert len(MessageCreate(mentions=[skill(i) for i in range(4)]).mentions) == 4
|
||||
|
||||
|
||||
def test_second_workflow_is_still_rejected():
|
||||
with pytest.raises(ValidationError, match="Only one @workflow"):
|
||||
MessageCreate(mentions=[workflow(), skill(), workflow(2)])
|
||||
|
||||
|
||||
def test_fifth_skill_is_rejected():
|
||||
with pytest.raises(ValidationError, match="At most four skill"):
|
||||
MessageCreate(mentions=[skill(i) for i in range(5)])
|
||||
@@ -197,6 +197,39 @@ def _confirm(client, pid: int, key: str, **extra):
|
||||
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/confirm", json=extra or None)
|
||||
|
||||
|
||||
def test_assistant_notes_keep_confirmation_and_survive_artifact_edits(client):
|
||||
pid = _make_project(client)
|
||||
note_url = f"/api/workbench/projects/{pid}/stages/style/note"
|
||||
r = client.patch(note_url, json={"text": "建议先比较冷暖两个方向。"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stage"]["status"] == "todo"
|
||||
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
|
||||
assert _confirm(client, pid, "style").status_code == 400
|
||||
# An ordinary structured save must keep the separate advice.
|
||||
r = client.patch(f"/api/workbench/projects/{pid}/stages/style", json={"content": json.loads(STYLE_REPLY)})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
|
||||
# Notes on confirmed content don't change its status or confirmed snapshot.
|
||||
client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
|
||||
before = _stage(_overview(client, pid), "script")
|
||||
r = client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "下一版建议加强人物动机。"})
|
||||
assert r.status_code == 200
|
||||
after = r.json()["stage"]
|
||||
assert after["status"] == before["status"] == "confirmed"
|
||||
assert after["confirmed_content"] == before["confirmed_content"]
|
||||
assert after["draft_edited"] == before["draft_edited"]
|
||||
assert _confirm(client, pid, "script").status_code == 200
|
||||
confirmed = _stage(_overview(client, pid), "script")
|
||||
assert "assistant_note" not in confirmed["confirmed_content"]
|
||||
client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "保留确认后的新建议。"})
|
||||
restored = client.post(f"/api/workbench/projects/{pid}/stages/script/restore").json()["stage"]
|
||||
assert restored["assistant_note"] == "保留确认后的新建议。"
|
||||
assert restored["confirmed_content"] == confirmed["confirmed_content"]
|
||||
assert client.patch(note_url, json={"text": ""}).json()["stage"]["assistant_note"] == ""
|
||||
assert client.patch(note_url, json={"text": "x" * 200_001}).status_code == 422
|
||||
assert client.patch(f"/api/workbench/projects/{pid}/stages/unknown/note", json={"text": "x"}).status_code == 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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) };
|
||||
};
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -834,6 +834,7 @@ export interface VideoShot {
|
||||
}
|
||||
|
||||
export interface WorkbenchStage {
|
||||
assistant_note?: string;
|
||||
stage_key: WorkbenchStageKey;
|
||||
status: WorkbenchStageStatus;
|
||||
content: Record<string, unknown> | null;
|
||||
@@ -857,6 +858,11 @@ export interface WorkbenchOverview {
|
||||
}
|
||||
|
||||
export const workbenchApi = {
|
||||
saveNote: (projectId: number, stageKey: WorkbenchStageKey, text: string) =>
|
||||
api<{ ok: boolean; stage: WorkbenchStage }>(
|
||||
`/api/workbench/projects/${projectId}/stages/${stageKey}/note`,
|
||||
{ method: "PATCH", body: JSON.stringify({ text }) },
|
||||
),
|
||||
overview: (projectId: number) =>
|
||||
api<WorkbenchOverview>(`/api/workbench/projects/${projectId}`),
|
||||
patchStage: (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
onclick={() => (zoomed = !zoomed)}
|
||||
>
|
||||
<Icon name="zoom-in" size={17} />
|
||||
@@ -108,7 +108,9 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
title={frameBusy
|
||||
? t('lastFrame.exporting')
|
||||
: `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={(e) => {
|
||||
@@ -127,6 +129,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
class="tool"
|
||||
href={src}
|
||||
download={downloadName}
|
||||
rel="external"
|
||||
aria-label={t('lightbox.downloadMedia')}
|
||||
title={t('lightbox.download')}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
@@ -179,9 +182,8 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
{alt}
|
||||
kind="video"
|
||||
label={t('lightbox.videoUnavailable')}
|
||||
autoplay
|
||||
controls
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -207,7 +209,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
{: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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
<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';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import {
|
||||
clearOutputMissing,
|
||||
markOutputMissing,
|
||||
missingOutputsOf,
|
||||
} from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from '../generate/schema';
|
||||
import {
|
||||
elapsedSec,
|
||||
fmtClock,
|
||||
fmtDur,
|
||||
inputsOf,
|
||||
isVideoOutput,
|
||||
promptOf,
|
||||
specOf,
|
||||
} from '../generate/takes';
|
||||
|
||||
/**
|
||||
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
|
||||
* stage with its spec, inputs and workflow; 参数 hosts the full form.
|
||||
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
|
||||
* fullscreen — `overlay` / `open` drive the last two.
|
||||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||||
* ↑↓/J/K 即时无动画), video playback is always explicit,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
main?: boolean;
|
||||
comparePath?: string | null;
|
||||
tab: 'result' | 'params';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
nowMs: number;
|
||||
open: boolean;
|
||||
/** Collapsed or off-screen — nothing inside may take focus. */
|
||||
inactive: boolean;
|
||||
/** Current workflow can take an image output (送入当前图片槽). */
|
||||
canSendToSlot: boolean;
|
||||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||||
crossfade: boolean;
|
||||
/** The stage <video> element, for explicit playback controls. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
canAttach?: boolean;
|
||||
attachBusy?: boolean;
|
||||
onAttach?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
main = false,
|
||||
comparePath = null,
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
nowMs,
|
||||
canSendToSlot,
|
||||
videoEl = $bindable(null),
|
||||
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),
|
||||
lightbox = $state(false);
|
||||
$effect(() => {
|
||||
void job?.id;
|
||||
detailsOpen = false;
|
||||
deleteOpen = false;
|
||||
compare = false;
|
||||
lightbox = false;
|
||||
});
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
});
|
||||
|
||||
const st = $derived(job ? jobStatusInfo(job) : null);
|
||||
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
|
||||
// cannot be fetched — the failed case stays red.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const outputs = $derived(
|
||||
job && st?.tone === 'ok'
|
||||
? job.output_paths.filter((p) => !gonePaths.includes(p))
|
||||
: [],
|
||||
);
|
||||
const output = $derived(
|
||||
outputs[Math.min(outIndex, outputs.length - 1)] ?? null,
|
||||
);
|
||||
const video = $derived(!!job && !!output && isVideoOutput(job, output));
|
||||
const wfDeleted = $derived(!!job && job.workflow_id == null);
|
||||
const prompt = $derived(job ? promptOf(job, wf) : '');
|
||||
const inputs = $derived(job ? inputsOf(job, wf) : []);
|
||||
const compareSource = $derived(
|
||||
inputs.find((i) => i.kind === 'image')?.path ?? comparePath,
|
||||
);
|
||||
const spec = $derived(job ? specOf(job, wf) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as
|
||||
string | undefined) ?? '',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || job.status !== 'running') return 0;
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
});
|
||||
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}`;
|
||||
}
|
||||
|
||||
let paused = $state(true);
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
untrack(() => {
|
||||
videoEl?.pause();
|
||||
paused = true;
|
||||
});
|
||||
});
|
||||
function togglePlayback() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
|
||||
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
const src = assetUrl(output);
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="artboard" aria-label="生成结果">
|
||||
<button
|
||||
class="artboard-toolbar"
|
||||
aria-label="作品详情与操作"
|
||||
onclick={() => (detailsOpen = true)}
|
||||
>
|
||||
<div class="result-title">
|
||||
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
|
||||
</div>
|
||||
</button>
|
||||
<div class="stage" class:comparing={compare}>
|
||||
{#if job && st}{#key mediaKey}
|
||||
<div
|
||||
class="stage-media"
|
||||
bind:clientWidth={stageWidth}
|
||||
bind:clientHeight={stageHeight}
|
||||
>
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<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}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
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)}
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
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"
|
||||
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}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{#if main && compare && compareSource}<div
|
||||
class="comparison-layer"
|
||||
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
|
||||
>
|
||||
<img src={assetUrl(compareSource)} alt="对比参考画面" />
|
||||
</div>
|
||||
<div
|
||||
class="comparison-divider"
|
||||
style:left={`${compareAt}%`}
|
||||
></div>{/if}
|
||||
</div>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
<span>{t('desk.missingTitle')}</span>
|
||||
<span class="sub">{t('desk.missingSub')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage-empty">
|
||||
<span>{st.label}</span>
|
||||
<span class="sub"
|
||||
>{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span
|
||||
>
|
||||
{#if progress > 0}
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}>
|
||||
<i></i>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}{/if}
|
||||
{#if outputs.length > 1}<div
|
||||
class="output-picker"
|
||||
role="group"
|
||||
aria-label="本次生成的作品"
|
||||
>
|
||||
<!-- 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
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
</div>
|
||||
<footer class="artboard-footer">
|
||||
<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"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={compareAt}
|
||||
aria-label="拖动对比画面"
|
||||
/>结果</label
|
||||
>{/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={() => {
|
||||
onReuse(job);
|
||||
detailsOpen = false;
|
||||
}}>复用到输入区</button
|
||||
>
|
||||
{#if output && !video && canSendToSlot}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => {
|
||||
onSendToSlot(output);
|
||||
detailsOpen = false;
|
||||
}}>送入当前图片槽</button
|
||||
>{/if}
|
||||
{#if video && output}<button
|
||||
class="a-btn secondary sm"
|
||||
disabled={frameBusy}
|
||||
onclick={exportTail}>{frameBusy ? '正在导出…' : '导出尾帧'}</button
|
||||
>{/if}
|
||||
{#if st.tone === 'fail' || fileGone}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => onRetry(job)}>用相同参数重做</button
|
||||
>{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}<button
|
||||
class="a-btn danger sm"
|
||||
onclick={() => {
|
||||
deleteOpen = true;
|
||||
detailsOpen = false;
|
||||
}}>移除记录</button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if st.tone === 'fail' || fileGone}<p class="error-note" role="alert">
|
||||
{fileGone ? t('jobst.missingDetail') : st.detail}
|
||||
</p>{/if}
|
||||
<div class="detail-meta">
|
||||
<!-- 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}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img
|
||||
class="mini"
|
||||
src={assetUrl(inp.path)}
|
||||
alt={inp.label}
|
||||
title={inp.label}
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}
|
||||
><Icon
|
||||
name={inp.kind === 'video' ? 'film' : 'music'}
|
||||
size={14}
|
||||
/></span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone"
|
||||
>{t('studio.wfDeletedTag')}</span
|
||||
>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</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 || '创作结果'}
|
||||
onClose={() => (lightbox = false)}
|
||||
/>{/if}
|
||||
|
||||
<style>
|
||||
.artboard {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 0 minmax(0, 1fr) 40px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
.artboard-toolbar,
|
||||
.artboard-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.result-title small {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
.canvas-btn {
|
||||
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 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: var(--bg-3);
|
||||
}
|
||||
.canvas-btn[aria-pressed='true'],
|
||||
.play-button {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.stage {
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
}
|
||||
.print {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
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%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.print :global(.a-fix) {
|
||||
display: none;
|
||||
}
|
||||
.stage-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.stage-empty .sub {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.stage-bar {
|
||||
width: 200px;
|
||||
}
|
||||
.comparison-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.comparison-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.artboard-footer {
|
||||
grid-row: 3;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.result-spec {
|
||||
margin-left: auto;
|
||||
}
|
||||
.compare-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.compare-slider input {
|
||||
width: 120px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.output-picker {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
max-width: 90%;
|
||||
overflow: auto;
|
||||
background: var(--media-scrim);
|
||||
border: 1px solid var(--line);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.output-picker button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.output-picker button[aria-pressed='true'] {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.detail-actions,
|
||||
.detail-meta,
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-meta {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
.mini {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.mini.glyph {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.prompt {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-2);
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 90px minmax(0, 1fr);
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error-note {
|
||||
color: var(--warn);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.result-title small {
|
||||
display: none;
|
||||
}
|
||||
.stage-media {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,631 @@
|
||||
<script lang="ts">
|
||||
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,
|
||||
onPick,
|
||||
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;
|
||||
onPick: (id: number) => void;
|
||||
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(untrack(() => initialUsage)),
|
||||
family = $state('all'),
|
||||
favOnly = $state(false),
|
||||
runnableOnly = $state(true),
|
||||
limit = $state(48),
|
||||
pins = $state<number[]>([]);
|
||||
onMount(() => {
|
||||
try {
|
||||
pins = JSON.parse(
|
||||
localStorage.getItem('calliope.reference.workflowPins') || '[]',
|
||||
);
|
||||
} catch {
|
||||
pins = [];
|
||||
}
|
||||
});
|
||||
const families = $derived([
|
||||
...new Set(metas.map((m) => m.model_family).filter(Boolean)),
|
||||
]);
|
||||
const usages = $derived([
|
||||
...new Set(metas.map((m) => m.usage).filter(Boolean)),
|
||||
]);
|
||||
const filtered = $derived(
|
||||
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.source_path,
|
||||
m.usage ? usageLabel(m.usage) : null,
|
||||
m.model_family,
|
||||
...(m.tags ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()),
|
||||
)
|
||||
.sort(
|
||||
(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'),
|
||||
),
|
||||
);
|
||||
$effect(() => {
|
||||
void search;
|
||||
void usage;
|
||||
void family;
|
||||
void favOnly;
|
||||
limit = 48;
|
||||
});
|
||||
$effect(() => {
|
||||
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];
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'calliope.reference.workflowPins',
|
||||
JSON.stringify(pins),
|
||||
);
|
||||
} catch {
|
||||
/* Pins remain usable this visit. */
|
||||
}
|
||||
}
|
||||
const favorite = createMutation({
|
||||
mutationFn: (m: WorkflowMeta) =>
|
||||
workflows.update(m.id, { favorite: !m.favorite }),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['workflows-meta'] }),
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={searchKeydown} />
|
||||
|
||||
<aside class="workflow-rail" aria-label="创作工作流列表">
|
||||
<header>
|
||||
<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"
|
||||
aria-selected={kind === 'image'}
|
||||
{disabled}
|
||||
onclick={() => onKind('image')}>图片</button
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={kind === 'video'}
|
||||
{disabled}
|
||||
onclick={() => onKind('video')}>视频</button
|
||||
>
|
||||
</div>
|
||||
<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="rail-list">
|
||||
{#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}
|
||||
>
|
||||
<button
|
||||
class="workflow-pick"
|
||||
{disabled}
|
||||
onclick={() => choose(m)}
|
||||
aria-pressed={selectedId === m.id}
|
||||
title={m.name}
|
||||
><i class:video={kind === 'video'}
|
||||
>{m.model_family?.slice(0, 3) || (kind === 'image' ? '◈' : '▷')}</i
|
||||
><span
|
||||
><b>{m.name}</b>{#if !runnable(m)}<small class="pending"
|
||||
>待配置 · 映射 / 测试 →</small
|
||||
>{/if}<small
|
||||
>{m.description ||
|
||||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
|
||||
></span
|
||||
></button
|
||||
>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
class:pressed={pins.includes(m.id)}
|
||||
aria-pressed={pins.includes(m.id)}
|
||||
aria-label={(pins.includes(m.id) ? '取消置顶' : '置顶') + m.name}
|
||||
onclick={() => pin(m.id)}>⌖</button
|
||||
><button
|
||||
aria-pressed={!!m.favorite}
|
||||
aria-label={(m.favorite ? '取消收藏' : '收藏') + m.name}
|
||||
disabled={$favorite.isPending}
|
||||
onclick={() => $favorite.mutate(m)}>{m.favorite ? '★' : '☆'}</button
|
||||
>
|
||||
</div>
|
||||
</div>{/each}
|
||||
{#if !filtered.length}<p class="empty">
|
||||
没有符合条件的工作流。可以清空搜索或更改筛选。
|
||||
</p>{/if}
|
||||
{#if filtered.length > limit}<button
|
||||
class="more"
|
||||
onclick={() => (limit += 48)}
|
||||
>再显示 {Math.min(filtered.length - limit, 48)} 个</button
|
||||
>{/if}
|
||||
</div>
|
||||
<footer>
|
||||
{#if pendingCount}<a href={resolve('/workflows?manage=1')}
|
||||
>{pendingCount} 个工作流待配置 →</a
|
||||
>{/if}<button class="manage" data-workflow-details onclick={onDetails}
|
||||
><span>+</span>
|
||||
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
|
||||
<b>›</b></button
|
||||
><a href={resolve('/models')}>云端模型与接口 ↗</a>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.workflow-rail {
|
||||
box-sizing: border-box;
|
||||
width: var(--list);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.workflow-rail > header {
|
||||
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: 14px;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
.rail-tools {
|
||||
padding: var(--desk-rail-gap) 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.kind-tabs {
|
||||
display: flex;
|
||||
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;
|
||||
border-radius: var(--desk-seg-selected-radius);
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kind-tabs [aria-selected='true'] {
|
||||
background: var(--bg-3);
|
||||
box-shadow: var(--sh-1);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.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-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;
|
||||
gap: 6px;
|
||||
padding: 8px 2px 0;
|
||||
font-size: var(--desk-filter-size);
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.family-label {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
padding: 8px;
|
||||
}
|
||||
.rail-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 8px 8px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.workflow-row {
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.workflow-row.selected {
|
||||
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: '';
|
||||
width: 3px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: var(--scene);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.workflow-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 8px;
|
||||
min-height: 55px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.workflow-pick i {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--scene);
|
||||
background: var(--bg-3);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
}
|
||||
.workflow-pick > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.workflow-pick b {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.workflow-pick small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row-actions button {
|
||||
opacity: 0;
|
||||
padding: 2px;
|
||||
height: 24px;
|
||||
width: 20px;
|
||||
border: 0;
|
||||
color: var(--text-3);
|
||||
background: none;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-actions button[aria-pressed='true'] {
|
||||
color: var(--warn);
|
||||
opacity: 1;
|
||||
}
|
||||
.workflow-row:hover .row-actions button,
|
||||
.workflow-row:focus-within .row-actions button {
|
||||
opacity: 1;
|
||||
}
|
||||
.workflow-row:hover {
|
||||
background-color: var(--bg-2);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
padding: 12px;
|
||||
}
|
||||
.more {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
footer {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
footer a,
|
||||
.manage {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
.manage {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 7px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.manage small {
|
||||
display: none;
|
||||
}
|
||||
.manage b {
|
||||
margin-left: auto;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.workflow-rail {
|
||||
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>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job } from '$lib/comfy/types';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { missingOutputsOf, markOutputMissing } from '$lib/missingOutputs.svelte';
|
||||
let {
|
||||
jobs,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onHistory,
|
||||
nameOf,
|
||||
}: {
|
||||
jobs: Job[];
|
||||
selectedId: number | null;
|
||||
onSelect: (job: Job) => void;
|
||||
onHistory: () => void;
|
||||
nameOf: (job: Job) => string;
|
||||
} = $props();
|
||||
const sorted = $derived(
|
||||
[...jobs].sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="result-rail" aria-label="生成结果记录">
|
||||
<header>
|
||||
<strong>生成记录 <small>{jobs.length}</small></strong><button
|
||||
class="a-btn ghost sm"
|
||||
onclick={onHistory}>全部记录与任务 ↗</button
|
||||
>
|
||||
</header>
|
||||
<div class="rail-items">
|
||||
{#each sorted as job (job.id)}
|
||||
{@const path = job.output_paths.find((p) => !missingOutputsOf(job).includes(p))}
|
||||
{@const status = jobStatusInfo(job)}
|
||||
<button
|
||||
class="result-item"
|
||||
class:selected={job.id === selectedId}
|
||||
aria-pressed={job.id === selectedId}
|
||||
aria-label="查看第 {job.id} 次生成,{status.label}"
|
||||
title="{nameOf(job)} · {job.created_at} · {status.label}"
|
||||
onclick={() => onSelect(job)}
|
||||
>
|
||||
<span class="thumb"
|
||||
>{#if path && job.kind === 'image'}<img
|
||||
src={assetUrl(path)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => markOutputMissing(path)}
|
||||
/>{:else}<span>{job.kind === 'video' ? '▶' : '✳'}</span>{/if}</span
|
||||
>
|
||||
<span class="record-label">{nameOf(job)}</span>
|
||||
<span class="record-label"
|
||||
>第 {job.id} 次 · {path
|
||||
? status.label
|
||||
: job.status === 'done'
|
||||
? '文件缺失'
|
||||
: status.label}</span
|
||||
>
|
||||
</button>
|
||||
{:else}<p>生成后,结果会保存在这里。</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.result-rail {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 8px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
gap: 10px;
|
||||
}
|
||||
strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
small {
|
||||
color: var(--text-3);
|
||||
margin-left: 5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.rail-items {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.result-item {
|
||||
width: 122px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 3px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.result-item.selected {
|
||||
border: 2px solid var(--accent);
|
||||
padding: 2px;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.thumb {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 48px;
|
||||
background: var(--bg);
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.record-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
padding: 5px 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin: 4px 0;
|
||||
}
|
||||
@media (max-height: 650px) {
|
||||
.rail-items {
|
||||
display: none;
|
||||
}
|
||||
.thumb,
|
||||
img {
|
||||
height: 32px;
|
||||
}
|
||||
.result-item {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,18 +1,13 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
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';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import LibraryFilters from './LibraryFilters.svelte';
|
||||
import LibraryRow from './LibraryRow.svelte';
|
||||
|
||||
/**
|
||||
* 工作流挑选台 (HEL-683, HEL-674 §二):中部可搜索筛选、按模型家族
|
||||
* 分组的挑选列。数据来自 S2 轻量元数据(view=meta,不含完整 JSON);
|
||||
* 云端来源只做诚实占位(未接入,不可点击使用)。键盘:↑↓/J/K 游走、
|
||||
* Enter 使用、Esc 关闭。长列表增量渲染 + content-visibility。
|
||||
*/
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
metas: WorkflowMeta[];
|
||||
@@ -22,467 +17,534 @@
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (id: number) => void;
|
||||
onClose: () => void;
|
||||
catalogue?: boolean;
|
||||
localOnly?: boolean;
|
||||
onPreview?: (id: number) => void;
|
||||
}
|
||||
|
||||
let { open, metas, pendingCount, selectedId, activeId = $bindable(null), endpoints, onPick, onClose }: Props =
|
||||
$props();
|
||||
|
||||
let search = $state('');
|
||||
let source = $state<'local' | 'cloud'>('local');
|
||||
let favOnly = $state(false);
|
||||
let visibleCount = $state(48);
|
||||
let searchEl = $state<HTMLInputElement | null>(null);
|
||||
let listEl = $state<HTMLElement | null>(null);
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
basic: 'wfCat.basic',
|
||||
audio_tts: 'wfCat.audioTts',
|
||||
music: 'wfCat.music',
|
||||
image: 'wfCat.image',
|
||||
video: 'wfCat.video',
|
||||
digital_human: 'wfCat.digitalHuman',
|
||||
motion_transfer: 'wfCat.motionTransfer',
|
||||
let {
|
||||
open,
|
||||
metas,
|
||||
pendingCount,
|
||||
selectedId,
|
||||
activeId = $bindable(null),
|
||||
onPick,
|
||||
onClose,
|
||||
catalogue = false,
|
||||
localOnly = false,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
const client = useQueryClient();
|
||||
const modelsQuery = createQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: settings.get,
|
||||
});
|
||||
let search = $state(''),
|
||||
category = $state('all'),
|
||||
family = $state('all'),
|
||||
source = $state('local'),
|
||||
usage = $state('all'),
|
||||
favOnly = $state(false),
|
||||
recentOnly = $state(false),
|
||||
visibleCount = $state(48);
|
||||
let searchEl: HTMLInputElement;
|
||||
let listEl: HTMLDivElement;
|
||||
const labels: Record<string, string> = {
|
||||
basic: '基础工具',
|
||||
audio_tts: '语音',
|
||||
music: '音乐',
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
digital_human: '数字人',
|
||||
motion_transfer: '动作',
|
||||
};
|
||||
function categoryLabel(key: string | null | undefined): string {
|
||||
if (!key) return '';
|
||||
const k = CATEGORY_LABELS[key];
|
||||
return k ? t(k) : key;
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return metas.filter((m) => {
|
||||
if (favOnly && !m.favorite) return false;
|
||||
if (!q) return true;
|
||||
const hay = [
|
||||
m.name,
|
||||
m.description ?? '',
|
||||
(m.tags ?? []).join(' '),
|
||||
m.model_family ?? '',
|
||||
m.usage ? usageLabel(m.usage) : '',
|
||||
categoryLabel(m.category),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
label: string;
|
||||
rows: WorkflowMeta[];
|
||||
}
|
||||
const groups = $derived.by(() => {
|
||||
const byFamily = new Map<string, WorkflowMeta[]>();
|
||||
for (const m of filtered) {
|
||||
const key = m.model_family ?? '';
|
||||
const list = byFamily.get(key) ?? [];
|
||||
list.push(m);
|
||||
byFamily.set(key, list);
|
||||
}
|
||||
const out: Group[] = [];
|
||||
for (const [key, rows] of byFamily) {
|
||||
rows.sort((a, b) => {
|
||||
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
|
||||
const la = a.last_used_at ?? '';
|
||||
const lb = b.last_used_at ?? '';
|
||||
if (la !== lb) return lb.localeCompare(la);
|
||||
return a.name.localeCompare(b.name, 'zh');
|
||||
});
|
||||
out.push({ key, label: key || t('desk.bayNoFamily'), rows });
|
||||
}
|
||||
out.sort((a, b) =>
|
||||
a.key === '' ? 1 : b.key === '' ? -1 : a.label.localeCompare(b.label, 'zh'),
|
||||
);
|
||||
return out;
|
||||
});
|
||||
|
||||
const flat = $derived(groups.flatMap((g) => g.rows));
|
||||
|
||||
/** Flattened rows cut at the incremental window, per group. */
|
||||
const windowed = $derived.by(() => {
|
||||
let left = visibleCount;
|
||||
return groups.map((g) => {
|
||||
const take = Math.max(0, Math.min(g.rows.length, left));
|
||||
left -= take;
|
||||
return { ...g, visible: g.rows.slice(0, take) };
|
||||
});
|
||||
});
|
||||
|
||||
// Any filter change restarts the incremental window.
|
||||
const categories = $derived([
|
||||
...new Set(metas.map((m) => m.category).filter((k): k is string => !!k)),
|
||||
]);
|
||||
const families = $derived(
|
||||
[...new Set(metas.map((m) => m.model_family).filter((k): k is string => !!k))].sort(),
|
||||
);
|
||||
const filtered = $derived(
|
||||
metas
|
||||
.filter(
|
||||
(m) =>
|
||||
(category === 'all' || m.category === category) &&
|
||||
(usage === 'all' || m.usage === usage) &&
|
||||
(family === 'all' || m.model_family === family) &&
|
||||
(!favOnly || m.favorite) &&
|
||||
(!recentOnly || m.last_used_at) &&
|
||||
(!search.trim() ||
|
||||
[
|
||||
m.name,
|
||||
m.description,
|
||||
...(m.tags ?? []),
|
||||
m.model_family,
|
||||
m.usage ? usageLabel(m.usage) : '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim())),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
recentOnly
|
||||
? (b.last_used_at ?? '').localeCompare(a.last_used_at ?? '')
|
||||
: Number(b.favorite) - Number(a.favorite) || a.name.localeCompare(b.name, 'zh'),
|
||||
),
|
||||
);
|
||||
const visible = $derived(filtered.slice(0, visibleCount));
|
||||
const cloudModels = $derived(
|
||||
($modelsQuery.data?.llm_profiles ?? []).filter(
|
||||
(p) => !search || [p.name, p.model].join(' ').toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
);
|
||||
$effect(() => {
|
||||
void search;
|
||||
void category;
|
||||
void family;
|
||||
void usage;
|
||||
void favOnly;
|
||||
void recentOnly;
|
||||
visibleCount = 48;
|
||||
listEl?.scrollTo({ top: 0 });
|
||||
});
|
||||
|
||||
// Keep the active row real: follow the selection, clamp on filter changes.
|
||||
$effect(() => {
|
||||
void flat;
|
||||
if (activeId != null && flat.some((m) => m.id === activeId)) return;
|
||||
activeId = flat.find((m) => m.id === selectedId)?.id ?? flat[0]?.id ?? null;
|
||||
});
|
||||
|
||||
// Opening the bay: the selected workflow's row must be inside the
|
||||
// incremental window and in view — otherwise the highlight is invisible.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const target = activeId ?? selectedId;
|
||||
const idx = flat.findIndex((m) => m.id === target);
|
||||
if (idx < 0) return;
|
||||
const idx = filtered.findIndex((m) => m.id === selectedId);
|
||||
if (idx >= visibleCount) visibleCount = idx + 48;
|
||||
const id = setTimeout(() => {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-wf-row="${target}"]`)
|
||||
?.scrollIntoView({ block: 'center' });
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
void tick().then(() => searchEl?.focus());
|
||||
});
|
||||
|
||||
const activeMeta = $derived(metas.find((m) => m.id === activeId) ?? null);
|
||||
const resultHint = $derived(
|
||||
source === 'cloud' ? '' : t('desk.bayCount', { n: filtered.length, total: metas.length }),
|
||||
);
|
||||
|
||||
// Autofocus the search whenever the bay opens.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const id = setTimeout(() => searchEl?.focus(), 0);
|
||||
return () => clearTimeout(id);
|
||||
if (!filtered.some((m) => m.id === activeId)) activeId = filtered[0]?.id ?? null;
|
||||
});
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!flat.length) return;
|
||||
let i = flat.findIndex((m) => m.id === activeId);
|
||||
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
|
||||
i = Math.max(0, Math.min(flat.length - 1, i));
|
||||
const next = flat[i];
|
||||
activeId = next.id;
|
||||
// Keyboard roving may step past the incremental window — extend it so
|
||||
// the row exists, then reveal it.
|
||||
if (i >= visibleCount) visibleCount = i + 24;
|
||||
void tick().then(() => {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-wf-row="${next.id}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function pickActive() {
|
||||
if (activeMeta) onPick(activeMeta.id);
|
||||
}
|
||||
|
||||
export function focusSearch() {
|
||||
searchEl?.focus();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const inSearch = e.target === searchEl;
|
||||
if (e.key === 'ArrowDown' || (!inSearch && e.key === 'j')) {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp' || (!inSearch && e.key === 'k')) {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
pickActive();
|
||||
async function favorite(m: WorkflowMeta) {
|
||||
try {
|
||||
await workflows.update(m.id, { favorite: !m.favorite });
|
||||
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const el = listEl;
|
||||
if (!el) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 240) {
|
||||
if (visibleCount < flat.length) visibleCount += 48;
|
||||
function pick(m: WorkflowMeta) {
|
||||
if (onPreview) onPreview(m.id);
|
||||
else if (isWorkflowRunnable(m)) onPick(m.id);
|
||||
}
|
||||
function move(delta: number) {
|
||||
const i = Math.max(
|
||||
0,
|
||||
Math.min(filtered.length - 1, filtered.findIndex((m) => m.id === activeId) + delta),
|
||||
);
|
||||
activeId = filtered[i]?.id ?? null;
|
||||
if (i >= visibleCount) visibleCount = i + 24;
|
||||
void tick().then(() =>
|
||||
listEl?.querySelector(`[data-wf-row="${activeId}"]`)?.scrollIntoView({ block: 'nearest' }),
|
||||
);
|
||||
}
|
||||
function key(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === 'Escape' && !catalogue) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (source !== 'local') {
|
||||
return;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
} else if (e.key === 'Enter' && e.target === searchEl) {
|
||||
e.preventDefault();
|
||||
const m = filtered.find((m) => m.id === activeId);
|
||||
if (m) pick(m);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="bay-backdrop" onclick={onClose} aria-hidden="true"></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<section
|
||||
class="bay"
|
||||
class:open
|
||||
aria-label={t('desk.pickWorkflow')}
|
||||
inert={!open}
|
||||
onkeydown={onKeydown}
|
||||
>
|
||||
<header class="bay-head">
|
||||
<h2 class="bay-title">
|
||||
<Icon name="playground" size={15} />
|
||||
<span>{t('desk.pickWorkflow')}</span>
|
||||
</h2>
|
||||
<button type="button" class="a-btn ghost sm close" onclick={onClose} title={t('desk.keyClose')}>
|
||||
<Icon name="close" size={14} />
|
||||
<span class="lbl">{t('desk.keyClose')}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="bay-filters">
|
||||
<LibraryFilters
|
||||
bind:searchEl
|
||||
{search}
|
||||
{source}
|
||||
{favOnly}
|
||||
{resultHint}
|
||||
onSetSearch={(v) => (search = v)}
|
||||
onSetSource={(s) => (source = s)}
|
||||
onToggleFav={() => (favOnly = !favOnly)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if source === 'cloud'}
|
||||
<div class="cloud-note" role="note">
|
||||
<Icon name="alert" size={16} />
|
||||
<p class="cloud-title">{t('desk.bayCloudTitle')}</p>
|
||||
<p class="cloud-body">{t('desk.bayCloudBody')}</p>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="catalogue" inert={!open} onkeydown={key}>
|
||||
<aside class="categories">
|
||||
<p>按创作任务寻找</p>
|
||||
<button
|
||||
class:active={category === 'all' && !favOnly && !recentOnly}
|
||||
onclick={() => {
|
||||
category = 'all';
|
||||
favOnly = false;
|
||||
recentOnly = false;
|
||||
}}><span>全部工具</span><small>{metas.length}</small></button
|
||||
>
|
||||
{#each categories as cat (cat)}<button
|
||||
class:active={category === cat}
|
||||
onclick={() => {
|
||||
category = cat;
|
||||
favOnly = false;
|
||||
recentOnly = false;
|
||||
}}
|
||||
><span>{labels[cat] ?? cat}</span><small
|
||||
>{metas.filter((m) => m.category === cat).length}</small
|
||||
></button
|
||||
>{/each}
|
||||
<div class="divider"></div>
|
||||
<button
|
||||
class:active={favOnly}
|
||||
onclick={() => {
|
||||
favOnly = !favOnly;
|
||||
recentOnly = false;
|
||||
}}><span>☆ 我的收藏</span><small>{metas.filter((m) => m.favorite).length}</small></button
|
||||
><button
|
||||
class:active={recentOnly}
|
||||
onclick={() => {
|
||||
recentOnly = !recentOnly;
|
||||
favOnly = false;
|
||||
}}><span>◷ 最近使用</span></button
|
||||
>
|
||||
</aside>
|
||||
<section class="catalogue-main">
|
||||
<label class="search"
|
||||
><span>⌕</span><input
|
||||
type="search"
|
||||
bind:this={searchEl}
|
||||
bind:value={search}
|
||||
placeholder="搜索工作流、模型或用途…"
|
||||
aria-label="搜索工作流"
|
||||
/></label
|
||||
>
|
||||
<div class="filters">
|
||||
<button class:active={source === 'local'} onclick={() => (source = 'local')}
|
||||
>本地 ComfyUI</button
|
||||
>{#if !localOnly}<button class:active={source === 'cloud'} onclick={() => (source = 'cloud')}
|
||||
>云端与智能体</button
|
||||
>{/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 (u)}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
|
||||
><select aria-label="筛选模型家族" bind:value={family}
|
||||
><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>
|
||||
{:else if !metas.length}
|
||||
<div class="bay-empty">
|
||||
<p>{t('desk.noWorkflows')}</p>
|
||||
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="bay-list" bind:this={listEl} role="listbox" aria-label={t('desk.pickWorkflow')} onscroll={onScroll}>
|
||||
{#each windowed as g (g.key)}
|
||||
{#if g.visible.length}
|
||||
<div class="group-label" aria-hidden="true">{g.label} · {g.rows.length}</div>
|
||||
{#each g.visible as m (m.id)}
|
||||
<div data-wf-row={m.id}>
|
||||
<LibraryRow
|
||||
meta={m}
|
||||
current={m.id === selectedId}
|
||||
active={m.id === activeId}
|
||||
{endpoints}
|
||||
onPick={() => onPick(m.id)}
|
||||
onActive={() => (activeId = m.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="results" bind:this={listEl}>
|
||||
{#if source === 'cloud'}
|
||||
<div class="cloud-context">
|
||||
<h3>让专业搭档加入创作。</h3>
|
||||
<p>使用已配置模型,在智能体或画布中创作。自定义多媒体接口与技能可在设置中管理。</p>
|
||||
<a href={resolve('/canvas')} class="a-btn primary">打开创作画布 ↗</a>
|
||||
</div>
|
||||
{#each cloudModels as profile (profile.id)}<article class="workflow-card">
|
||||
<div class="family">{profile.model}<span>已配置模型</span></div>
|
||||
<h3>{profile.name}</h3>
|
||||
<a href={resolve('/agents')} class="tool-link">交给智能体创作 ↗</a>
|
||||
</article>{:else}<p class="empty">
|
||||
还没有匹配的模型。<a href={resolve('/models')}>配置模型 ↗</a>
|
||||
</p>{/each}
|
||||
{:else}
|
||||
<p class="bay-none">{t('desk.wfNoMatch')}</p>
|
||||
{/each}
|
||||
{#if visibleCount < flat.length}
|
||||
<button type="button" class="more a-btn ghost sm" onclick={() => (visibleCount += 48)}>
|
||||
{t('desk.bayMore', { n: Math.min(48, flat.length - visibleCount) })}
|
||||
</button>
|
||||
{#each visible as m (m.id)}<article
|
||||
class="workflow-card"
|
||||
class:selected={m.id === selectedId}
|
||||
class:roving={m.id === activeId}
|
||||
data-wf-row={m.id}
|
||||
>
|
||||
<button
|
||||
class="workflow-choice"
|
||||
onclick={() => pick(m)}
|
||||
onfocus={() => (activeId = m.id)}
|
||||
disabled={!onPreview && (!m.is_enabled || m.needs_mapping || !m.usage)}
|
||||
aria-label="{onPreview ? '查看' : '使用'} {m.name}"
|
||||
aria-current={m.id === selectedId ? 'true' : undefined}
|
||||
>
|
||||
<div class="family">
|
||||
{m.model_family ?? 'COMFYUI'}<span>本地</span>
|
||||
</div>
|
||||
<h3 title={m.name}>{m.name}</h3>
|
||||
{#if m.description}<p class="description">{m.description}</p>{/if}
|
||||
<div class="card-meta">
|
||||
<span>{m.usage ? usageLabel(m.usage) : '待映射'}</span><InputRequirementBadge
|
||||
io={m.media_io}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="favorite"
|
||||
class:saved={m.favorite}
|
||||
aria-label="{m.favorite ? '取消收藏' : '收藏'} {m.name}"
|
||||
aria-pressed={m.favorite}
|
||||
onclick={() => favorite(m)}>{m.favorite ? '★' : '☆'}</button
|
||||
>
|
||||
{#if m.needs_mapping || !m.is_enabled || !m.usage}<a
|
||||
href={resolve('/workflows?manage=1')}
|
||||
class="tool-link">{m.needs_mapping ? '完成映射' : '管理工作流'} ↗</a
|
||||
>{/if}
|
||||
</article>{:else}<p class="empty">没有找到匹配的工作流,试试更短的关键词。</p>{/each}
|
||||
{#if visibleCount < filtered.length}<button
|
||||
class="more a-btn secondary"
|
||||
onclick={() => (visibleCount += 48)}
|
||||
>再看 {Math.min(48, filtered.length - visibleCount)} 个工具</button
|
||||
>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<footer class="bay-foot">
|
||||
{#if pendingCount > 0}
|
||||
<a class="pending-link" href="/connections">
|
||||
{t('desk.bayPending', { n: pendingCount })}
|
||||
<Icon name="chevron-right" size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
{#if activeMeta}
|
||||
<span class="active-name" title={activeMeta.name}>{activeMeta.name}</span>
|
||||
{/if}
|
||||
<span class="keys">
|
||||
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
|
||||
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
|
||||
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
|
||||
</span>
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
{#if pendingCount > 0}<p class="pending">
|
||||
另有 {pendingCount} 个工作流待映射。<a href={resolve('/workflows?manage=1')}>去整理 ↗</a>
|
||||
</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.description {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.catalogue {
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
}
|
||||
.catalogue-main {
|
||||
padding-left: 12px;
|
||||
}
|
||||
.results {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.catalogue {
|
||||
display: grid;
|
||||
grid-template-columns: 145px minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bay-head {
|
||||
.categories {
|
||||
padding: 16px 15px 20px 0;
|
||||
border-right: 1px solid var(--line);
|
||||
overflow: auto;
|
||||
}
|
||||
.categories p {
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-3);
|
||||
margin: 0 9px 15px;
|
||||
}
|
||||
.categories button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-2) var(--sp-4);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
padding: 11px 10px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bay-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
.categories button.active {
|
||||
background: #e8eafe;
|
||||
color: var(--accent);
|
||||
}
|
||||
.bay-title :global(svg) {
|
||||
.categories small {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
.divider {
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 20px 8px 12px;
|
||||
}
|
||||
.close .lbl {
|
||||
display: none;
|
||||
}
|
||||
.bay-filters {
|
||||
padding: 0 var(--sp-1) var(--sp-3);
|
||||
}
|
||||
.bay-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
.catalogue-main {
|
||||
padding: 16px 0 0 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--sp-1);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.group-label {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: var(--sp-3) var(--sp-3) var(--sp-1);
|
||||
background: linear-gradient(var(--bg-2) 82%, transparent);
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
padding: 9px 13px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.search input {
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.search:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
margin: 16px 0 21px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters button,
|
||||
.filters select {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--surface);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button.active {
|
||||
color: var(--surface);
|
||||
background: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
.filters span {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
.filters select {
|
||||
max-width: 140px;
|
||||
}
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 11px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
padding: 2px 5px 15px 2px;
|
||||
}
|
||||
.workflow-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
padding: 14px;
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 132px;
|
||||
}
|
||||
.workflow-card.selected {
|
||||
border-color: var(--accent);
|
||||
background: #f1f3ff;
|
||||
}
|
||||
.workflow-choice {
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 0 0 9px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.workflow-choice:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
.family {
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.family span {
|
||||
font: 8px var(--font);
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 9px;
|
||||
font-weight: 550;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
padding-right: 14px;
|
||||
}
|
||||
.favorite {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
font-size: 18px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #838a78;
|
||||
cursor: pointer;
|
||||
}
|
||||
.favorite.saved {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tool-link {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.cloud-context {
|
||||
grid-column: 1/-1;
|
||||
background: #e9e7df;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.cloud-context h3 {
|
||||
font-size: 19px;
|
||||
}
|
||||
.cloud-context p {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.cloud-context .a-btn {
|
||||
font-size: 11px;
|
||||
}
|
||||
.pending {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 0;
|
||||
}
|
||||
.empty {
|
||||
grid-column: 1/-1;
|
||||
padding: 45px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.more {
|
||||
align-self: center;
|
||||
margin: var(--sp-3) 0;
|
||||
grid-column: 1/-1;
|
||||
justify-self: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
.bay-none,
|
||||
.bay-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
text-align: center;
|
||||
}
|
||||
.bay-empty {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.bay-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-2) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.pending-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pending-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.active-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
.cloud-note {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-4) var(--sp-2);
|
||||
padding: var(--sp-6);
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.cloud-title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cloud-body {
|
||||
margin: 0;
|
||||
font-size: var(--fs-aux);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 窄/中屏:挑选台改为全屏浮层(计划中的最简可用降级)。 */
|
||||
.bay-backdrop {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.bay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: var(--topbar-h);
|
||||
bottom: 0;
|
||||
z-index: 60;
|
||||
background: var(--bg);
|
||||
box-shadow: var(--shadow-float);
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
opacity var(--d-state) ease;
|
||||
@media (hover: hover) {
|
||||
.workflow-card:hover {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.bay.open {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.bay-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: var(--topbar-h) 0 0 0;
|
||||
z-index: 59;
|
||||
background: var(--overlay);
|
||||
}
|
||||
.close .lbl {
|
||||
display: inline;
|
||||
}
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .bay {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.bay {
|
||||
top: 0;
|
||||
padding: var(--sp-4) var(--sp-4) calc(var(--tabbar-h) + env(safe-area-inset-bottom));
|
||||
}
|
||||
.bay-backdrop {
|
||||
inset: 0;
|
||||
.categories button:hover {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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. */
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import {
|
||||
clearOutputMissing,
|
||||
markOutputMissing,
|
||||
missingOutputsOf,
|
||||
} from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from './schema';
|
||||
@@ -18,11 +23,13 @@
|
||||
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
|
||||
* fullscreen — `overlay` / `open` drive the last two.
|
||||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||||
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
|
||||
* ↑↓/J/K 即时无动画), video playback is always explicit,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
main?: boolean;
|
||||
comparePath?: string | null;
|
||||
tab: 'result' | 'params';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
@@ -34,19 +41,20 @@
|
||||
canSendToSlot: boolean;
|
||||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||||
crossfade: boolean;
|
||||
/** Bump to auto-play the stage video (pointer pick / completion). */
|
||||
playSignal: number;
|
||||
/** The stage <video> element, for the desk's space-bar toggle. */
|
||||
/** The stage <video> element, for explicit playback controls. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
main = false,
|
||||
comparePath = null,
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
@@ -54,18 +62,25 @@
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
playSignal,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
let outIndex = $state(0);
|
||||
let compare = $state(false),
|
||||
compareAt = $state(50),
|
||||
lightbox = $state(false);
|
||||
$effect(() => {
|
||||
void job?.id;
|
||||
compare = false;
|
||||
lightbox = false;
|
||||
});
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
@@ -76,16 +91,20 @@
|
||||
// cannot be fetched — the failed case stays red.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
|
||||
const outputs = $derived(
|
||||
job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : [],
|
||||
);
|
||||
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) : '');
|
||||
const inputs = $derived(job ? inputsOf(job, wf) : []);
|
||||
const compareSource = $derived(inputs.find((i) => i.kind === 'image')?.path ?? comparePath);
|
||||
const spec = $derived(job ? specOf(job, wf) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
||||
'',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || job.status !== 'running') return 0;
|
||||
@@ -108,25 +127,18 @@
|
||||
dims = `${v.videoWidth}×${v.videoHeight}`;
|
||||
}
|
||||
|
||||
// 完成或点选后自动播放(静音);键盘切换不触发。
|
||||
$effect(() => {
|
||||
void playSignal;
|
||||
if (inactive || tab !== 'result' || !videoEl) return;
|
||||
videoEl.muted = true;
|
||||
videoEl.play().catch(() => {});
|
||||
let paused = $state(true);
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
untrack(() => {
|
||||
videoEl?.pause();
|
||||
paused = true;
|
||||
});
|
||||
});
|
||||
|
||||
function reduced(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
|
||||
function xfade(_node: Element) {
|
||||
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
|
||||
return { duration: ms, css: (t: number) => `opacity: ${t}` };
|
||||
function togglePlayback() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
|
||||
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
|
||||
@@ -144,14 +156,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
<aside class="inspector" class:main class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
<div class="tabs">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}
|
||||
>{t('desk.tabResult')}</button
|
||||
>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}
|
||||
>{t('desk.tabParams')}</button
|
||||
>
|
||||
</div>
|
||||
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
|
||||
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn ghost sm close"
|
||||
aria-label={t('desk.closeInspector')}
|
||||
onclick={onClose}
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -160,11 +181,17 @@
|
||||
{#if tab === 'params'}
|
||||
{@render params()}
|
||||
{:else if !job || !st}
|
||||
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
|
||||
<div class="a-stage stage">
|
||||
<div class="stage-empty">
|
||||
<span class="empty-symbol">✳</span><strong>给想象留一个位置。</strong><span class="sub"
|
||||
>在右侧写下描述,或选择一次已有创作。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="a-stage stage">
|
||||
{#key mediaKey}
|
||||
<div class="stage-media" in:xfade out:xfade>
|
||||
<div class="stage-media">
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<div class="print a-photo">
|
||||
@@ -173,6 +200,10 @@
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
aria-label="生成的视频,使用播放按钮开始播放"
|
||||
onplay={() => (paused = false)}
|
||||
onpause={() => (paused = true)}
|
||||
onended={() => (paused = true)}
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
@@ -194,6 +225,13 @@
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{#if main && compare && compareSource}<div
|
||||
class="comparison-layer"
|
||||
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
|
||||
>
|
||||
<img src={assetUrl(compareSource)} alt="对比参考画面" />
|
||||
</div>
|
||||
<div class="comparison-divider" style:left={`${compareAt}%`}></div>{/if}
|
||||
</div>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
@@ -205,7 +243,9 @@
|
||||
<span>{st.label}</span>
|
||||
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
|
||||
{#if progress > 0}
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}>
|
||||
<i></i>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -213,6 +253,19 @@
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if output && video}<div class="playback-bar">
|
||||
<button
|
||||
class="a-btn primary sm"
|
||||
onclick={togglePlayback}
|
||||
aria-label={paused ? '播放视频' : '暂停视频'}
|
||||
>{paused ? '▶ 播放视频' : 'Ⅱ 暂停视频'}</button
|
||||
>
|
||||
<span>视频默认暂停,由你控制播放。</span>
|
||||
<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => videoEl?.requestFullscreen().catch(() => {})}>全屏查看 ⤢</button
|
||||
>
|
||||
</div>{/if}
|
||||
{#if allOutputs.length > 1}
|
||||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||||
{#each allOutputs as p, i (p)}
|
||||
@@ -241,23 +294,49 @@
|
||||
<span>{s.label} <b class="mono">{s.value}</b></span>
|
||||
{/if}
|
||||
{/each}
|
||||
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
|
||||
<span
|
||||
>{t('desk.elapsed')}
|
||||
<b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span
|
||||
>
|
||||
{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if main && compare && output && !video}<div class="compare-controls">
|
||||
<span>{inputs.some((i) => i.kind === 'image') ? '参考图片' : '上一版'}</span><input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={compareAt}
|
||||
aria-label="拖动对比画面"
|
||||
/><span>当前结果</span>
|
||||
</div>{/if}
|
||||
<div class="actions">
|
||||
{#if main && output && !video}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => (lightbox = true)}>放大查看 ⤢</button
|
||||
>{#if compareSource && compareSource !== output}<button
|
||||
class="a-btn secondary sm"
|
||||
aria-pressed={compare}
|
||||
onclick={() => (compare = !compare)}>{compare ? '结束对比' : '对比画面'} ◫</button
|
||||
>{/if}{/if}
|
||||
{#if fileGone && !outputs.length}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}
|
||||
>{t('desk.redo')}</button
|
||||
>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}
|
||||
>{t('desk.removeRecord')}</button
|
||||
>
|
||||
{:else}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}
|
||||
>{t('desk.reuseToComposer')}</button
|
||||
>
|
||||
{#if output && !video && canSendToSlot}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
|
||||
{t('desk.sendToSlot')}
|
||||
</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
|
||||
@@ -272,10 +351,18 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#if st.tone === 'fail'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}
|
||||
>{t('common.retry')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if job.status === 'pending' || job.status === 'running'}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => onCancel(job)}>取消这次生成</button
|
||||
>{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}
|
||||
>{t('studio.deleteBtn')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -286,44 +373,61 @@
|
||||
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
|
||||
{/if}
|
||||
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<details class="result-details">
|
||||
<summary>创作详情与参数</summary>
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img
|
||||
class="mini"
|
||||
src={assetUrl(inp.path)}
|
||||
alt={inp.label}
|
||||
title={inp.label}
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}
|
||||
><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#if lightbox && output && !video}<ImageLightbox
|
||||
src={assetUrl(output) ?? ''}
|
||||
alt={prompt || '创作结果'}
|
||||
onClose={() => (lightbox = false)}
|
||||
/>{/if}
|
||||
|
||||
<style>
|
||||
.inspector {
|
||||
display: flex;
|
||||
@@ -578,4 +682,206 @@
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
}
|
||||
|
||||
.inspector.main {
|
||||
position: static;
|
||||
inset: auto;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main .tabs {
|
||||
display: none;
|
||||
}
|
||||
.main .body {
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.main .stage {
|
||||
aspect-ratio: auto;
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
flex: 1;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #eae9e3;
|
||||
}
|
||||
.main .stage-media {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.main .print {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.main .print img,
|
||||
.main .print video {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 0;
|
||||
}
|
||||
.main .meta,
|
||||
.main .actions,
|
||||
.main .playback-bar,
|
||||
.main .strip,
|
||||
.main .note,
|
||||
.main .result-details {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.main .meta {
|
||||
font-size: 10px;
|
||||
gap: 8px;
|
||||
line-height: 1.5;
|
||||
max-height: 30px;
|
||||
overflow: auto;
|
||||
}
|
||||
.main .actions {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.main .result-details {
|
||||
font-size: 11px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
.playback-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.playback-bar span {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
@media (max-height: 650px) {
|
||||
.playback-bar span {
|
||||
display: none;
|
||||
}
|
||||
.main .meta {
|
||||
max-height: 16px;
|
||||
}
|
||||
.main .actions :global(.a-btn) {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
.main .a-fix {
|
||||
display: none;
|
||||
}
|
||||
.main .stage-empty {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty-symbol {
|
||||
color: var(--accent);
|
||||
font-size: 55px;
|
||||
font-weight: 300;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.stage-empty strong {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.main .meta {
|
||||
font-size: 9px;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.main .actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result-details {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result-details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.result-details .prompt {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.comparison-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.comparison-layer img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #eae9e3;
|
||||
}
|
||||
.comparison-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 8px #292a2d40;
|
||||
pointer-events: none;
|
||||
}
|
||||
.compare-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.compare-controls input {
|
||||
flex: 1;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { validateForm, classifySchema, type FormValues } from './schema';
|
||||
let {
|
||||
inputs,
|
||||
values,
|
||||
anchor,
|
||||
onClose,
|
||||
onApply,
|
||||
}: {
|
||||
inputs: ComfyDynamicInput[];
|
||||
values: FormValues;
|
||||
anchor: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onApply: (values: FormValues) => void;
|
||||
} = $props();
|
||||
let draft = $state<FormValues>({});
|
||||
let showErrors = $state(false);
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let x = $state(0),
|
||||
y = $state(0);
|
||||
const errors = $derived(
|
||||
validateForm(inputs, classifySchema(inputs, 'image'), draft),
|
||||
);
|
||||
$effect(() => {
|
||||
if (!anchor || !inputs.length) return;
|
||||
const source = anchor;
|
||||
draft = Object.fromEntries(
|
||||
inputs.map((i) => [i.nodeId, values[i.nodeId] ?? i.defaultValue ?? '']),
|
||||
);
|
||||
showErrors = false;
|
||||
const rect = source.getBoundingClientRect();
|
||||
x = Math.max(12, Math.min(rect.left, innerWidth - 312));
|
||||
y = Math.max(12, innerHeight - rect.top + 8);
|
||||
void tick().then(() =>
|
||||
panel
|
||||
?.querySelector<HTMLElement>('input,select,textarea,button')
|
||||
?.focus(),
|
||||
);
|
||||
const closeOutside = (e: PointerEvent) => {
|
||||
if (
|
||||
!panel?.contains(e.target as Node) &&
|
||||
!source.contains(e.target as Node)
|
||||
)
|
||||
onClose();
|
||||
};
|
||||
const keys = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Tab' && panel) {
|
||||
const all = [
|
||||
...panel.querySelectorAll<HTMLElement>(
|
||||
'input,select,textarea,button',
|
||||
),
|
||||
].filter(
|
||||
(e) => !e.hasAttribute('disabled') && e.getClientRects().length,
|
||||
);
|
||||
if (e.shiftKey && document.activeElement === all[0]) {
|
||||
e.preventDefault();
|
||||
all.at(-1)?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === all.at(-1)) {
|
||||
e.preventDefault();
|
||||
all[0]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', closeOutside);
|
||||
document.addEventListener('keydown', keys, true);
|
||||
window.addEventListener('resize', onClose);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOutside);
|
||||
document.removeEventListener('keydown', keys, true);
|
||||
window.removeEventListener('resize', onClose);
|
||||
if (source.isConnected) source.focus();
|
||||
};
|
||||
});
|
||||
function save() {
|
||||
if (Object.keys(errors).length) {
|
||||
showErrors = true;
|
||||
return;
|
||||
}
|
||||
onApply(draft);
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if inputs.length && anchor}
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="parameter-popover"
|
||||
role="dialog"
|
||||
aria-label={inputs.map((i) => i.label).join(' / ')}
|
||||
style:left={`${x}px`}
|
||||
style:bottom={`${y}px`}
|
||||
>
|
||||
<header>{inputs.map((i) => i.label).join(' / ')}</header>
|
||||
<ComfyDynamicForm
|
||||
{inputs}
|
||||
values={draft as Record<string, string | number>}
|
||||
quiet
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => (draft = { ...draft, ...v })}
|
||||
/>
|
||||
<footer>
|
||||
<button class="a-btn ghost sm" onclick={onClose}>取消</button><button
|
||||
class="a-btn secondary sm"
|
||||
onclick={save}>应用参数</button
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.parameter-popover {
|
||||
position: fixed;
|
||||
z-index: 800;
|
||||
width: 300px;
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100dvh - 36px);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
background: var(--float-bg);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-float);
|
||||
transform-origin: bottom left;
|
||||
animation: appear var(--d-pop) var(--ease-out);
|
||||
}
|
||||
header {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@keyframes appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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';
|
||||
@@ -35,7 +36,13 @@
|
||||
);
|
||||
|
||||
// Five-stage creation progress (HEL-614) — real stage rows only.
|
||||
const STAGE_SEGMENTS = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
|
||||
const STAGE_SEGMENTS = [
|
||||
'script',
|
||||
'style',
|
||||
'storyboard',
|
||||
'assets',
|
||||
'shot',
|
||||
] as const;
|
||||
function segClass(key: string): string {
|
||||
const state = stats.stage_status?.[key];
|
||||
if (state === 'confirmed') return 'seg-confirmed';
|
||||
@@ -46,18 +53,16 @@
|
||||
|
||||
// Spec §2.1 allows the five avatar gradients as cover placeholders.
|
||||
const PLACEHOLDER_GRADIENTS = [
|
||||
'var(--avatar-1)',
|
||||
'var(--avatar-2)',
|
||||
'var(--avatar-3)',
|
||||
'var(--avatar-4)',
|
||||
'var(--avatar-5)',
|
||||
'#1f262c',
|
||||
'#253036',
|
||||
'#2d2333',
|
||||
'#26372c',
|
||||
'#302836',
|
||||
];
|
||||
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
|
||||
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
|
||||
const placeholderIconColor = $derived(
|
||||
placeholderIdx <= 1 ? 'var(--accent-ink)' : 'var(--text)',
|
||||
);
|
||||
const placeholderIconColor = $derived('var(--text-2)');
|
||||
|
||||
const coverUrl = $derived(assetUrl(project.cover_path));
|
||||
|
||||
@@ -69,17 +74,28 @@
|
||||
if (stats.stage_confirmed >= STAGE_SEGMENTS.length) {
|
||||
return { cls: 'st-done', label: t('home.statusDone') };
|
||||
}
|
||||
const current = STAGE_SEGMENTS.find((k) => stats.stage_status?.[k] !== 'confirmed');
|
||||
const current = STAGE_SEGMENTS.find(
|
||||
(k) => stats.stage_status?.[k] !== 'confirmed',
|
||||
);
|
||||
const state = current ? stats.stage_status?.[current] : undefined;
|
||||
const stageName = current ? t(`workbench.stage.${current}`) : '';
|
||||
if (state === 'running') {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
|
||||
return {
|
||||
cls: 'st-progress',
|
||||
label: `${stageName} · ${t('workbench.state.running')}`,
|
||||
};
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
return {
|
||||
cls: 'st-pending',
|
||||
label: `${stageName} · ${t('workbench.state.draft')}`,
|
||||
};
|
||||
}
|
||||
if (stats.stage_confirmed > 0) {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
|
||||
return {
|
||||
cls: 'st-progress',
|
||||
label: `${stageName} · ${t('home.statusProgress')}`,
|
||||
};
|
||||
}
|
||||
return { cls: 'st-draft', label: t('home.statusDraft') };
|
||||
});
|
||||
@@ -137,7 +153,9 @@
|
||||
toast.success(t('projectCard.renamedTo', { title: renameValue }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projectCard.renameFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,7 +175,9 @@
|
||||
toast.success(t('projectCard.deleted', { title: project.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projectCard.deleteFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -167,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}
|
||||
@@ -185,7 +205,9 @@
|
||||
style:color={placeholderIconColor}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="film" size={22} />
|
||||
<span class="cover-type"
|
||||
>✳<small>STORY / {String(project.id).padStart(3, '0')}</small></span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="status-tag {cover.cls}">{cover.label}</span>
|
||||
@@ -198,11 +220,14 @@
|
||||
<span class="idea">{project.idea || t('home.noIdea')}</span>
|
||||
<span class="stage-bar" aria-label={t('home.stageProgress')}>
|
||||
{#each STAGE_SEGMENTS as key (key)}
|
||||
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}></span>
|
||||
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}
|
||||
></span>
|
||||
{/each}
|
||||
<span class="seg-count mono">{stats.stage_confirmed}/5</span>
|
||||
{#if stats.active_jobs > 0}
|
||||
<span class="jobs-pill">{t('home.activeJobs', { n: stats.active_jobs })}</span>
|
||||
<span class="jobs-pill"
|
||||
>{t('home.activeJobs', { n: stats.active_jobs })}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="meta">
|
||||
@@ -232,7 +257,12 @@
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
onclick={openRename}
|
||||
>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('common.rename')}
|
||||
</button>
|
||||
@@ -266,7 +296,9 @@
|
||||
</label>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}
|
||||
>{t('common.cancel')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim() || $renameMutation.isPending}
|
||||
@@ -534,9 +566,81 @@
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: var(--error-dim);
|
||||
} .a-card :global(.field-input) {
|
||||
}
|
||||
.a-card :global(.field-input) {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.a-card {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.cover {
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.body {
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
.status-tag {
|
||||
top: auto;
|
||||
left: 12px;
|
||||
right: auto;
|
||||
bottom: 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px 7px;
|
||||
background: #121218ed;
|
||||
color: var(--text);
|
||||
font-size: 9px;
|
||||
}
|
||||
.cover-type {
|
||||
font-size: 56px;
|
||||
font-weight: 300;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.cover-type small {
|
||||
display: block;
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.cover-img {
|
||||
transition: transform 250ms var(--ease-out);
|
||||
}
|
||||
.stage-bar {
|
||||
width: 100%;
|
||||
}
|
||||
.seg {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
}
|
||||
.meta-stats,
|
||||
.meta-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
.idea {
|
||||
font-size: 11px;
|
||||
min-height: 0;
|
||||
}
|
||||
.menu-wrap {
|
||||
left: auto;
|
||||
right: 10px;
|
||||
}
|
||||
.menu-btn {
|
||||
background: var(--surface);
|
||||
}
|
||||
.menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.a-card:hover .cover-img {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let { onStart, onImport }: { onStart: () => void; onImport: () => void } =
|
||||
$props();
|
||||
let card: HTMLDivElement;
|
||||
onMount(() => {
|
||||
let raf = 0,
|
||||
x = 0,
|
||||
v = 0,
|
||||
target = 0,
|
||||
time = 0;
|
||||
const reduced = () =>
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
|
||||
document.documentElement.dataset.reduce === '1';
|
||||
function animate(now: number) {
|
||||
const dt = Math.min((now - time) / 1000 || 0.016, 0.032);
|
||||
time = now;
|
||||
v += ((target - x) * 100 - v * 10) * dt;
|
||||
x += v * dt;
|
||||
card.style.transform = `rotate(${10 + x}deg) translateY(${-Math.abs(x)}px)`;
|
||||
if (Math.abs(v) + Math.abs(target - x) > 0.01)
|
||||
raf = requestAnimationFrame(animate);
|
||||
else raf = 0;
|
||||
}
|
||||
function go(value: number) {
|
||||
if (reduced()) return;
|
||||
target = value;
|
||||
if (!raf) {
|
||||
time = performance.now();
|
||||
raf = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
const enter = () => go(-5),
|
||||
leave = () => go(0);
|
||||
card.addEventListener('pointerenter', enter);
|
||||
card.addEventListener('pointerleave', leave);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
card.removeEventListener('pointerenter', enter);
|
||||
card.removeEventListener('pointerleave', leave);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="refract-hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">● A SPACE FOR THE UNEXPECTED</div>
|
||||
<h1>让想象,<br /><span>在这里成形。</span></h1>
|
||||
<p>
|
||||
从一个念头,到一组画面,再到一段故事。<br
|
||||
/>你的模型、素材与灵感,都在同一个工作室。
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<button class="a-btn primary" onclick={onStart}>开始创作 →</button><button
|
||||
class="text-button"
|
||||
onclick={onImport}>导入已有剧本 ↗</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-art">
|
||||
<img
|
||||
class="hero-photo"
|
||||
src="/refract/coast.jpg"
|
||||
alt="海岸与薄雾构成的灵感画面"
|
||||
/>
|
||||
<div class="art-circle"></div>
|
||||
<span class="hero-star">✳</span>
|
||||
<div class="art-label">
|
||||
VISUAL STUDY / 001<br />LIGHT, SPACE & POSSIBILITY
|
||||
</div>
|
||||
<div class="hero-card" bind:this={card}>
|
||||
<img src="/refract/arch.jpg" alt="建筑几何参考" /><span
|
||||
>FOUND IN THE IN-BETWEEN</span
|
||||
>
|
||||
</div>
|
||||
<div class="art-word">Beyond<br />the ordinary.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.refract-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.12fr;
|
||||
min-height: 322px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(ellipse at 0 0, #7850ff1a, transparent 70%), #141419;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.hero-copy {
|
||||
padding: 35px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.3px;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(33px, 3vw, 47px);
|
||||
line-height: 1.3;
|
||||
margin: 0 0 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -1.6px;
|
||||
}
|
||||
h1 span {
|
||||
color: var(--accent);
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
color: var(--text-3);
|
||||
margin: 0 0 23px;
|
||||
}
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-buttons .a-btn {
|
||||
height: 41px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hero-art {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #d2dbe4;
|
||||
min-height: 322px;
|
||||
isolation: isolate;
|
||||
}
|
||||
.hero-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 70%;
|
||||
filter: contrast(0.92) saturate(0.7);
|
||||
}
|
||||
.art-circle {
|
||||
position: absolute;
|
||||
width: 270px;
|
||||
height: 270px;
|
||||
border: 1px solid #ffffff88;
|
||||
border-radius: 50%;
|
||||
top: 28px;
|
||||
left: 75px;
|
||||
z-index: 1;
|
||||
}
|
||||
.art-circle:before,
|
||||
.art-circle:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border: 1px solid #ffffff66;
|
||||
border-radius: 50%;
|
||||
inset: 26px;
|
||||
}
|
||||
.art-circle:after {
|
||||
inset: 57px;
|
||||
}
|
||||
.art-word {
|
||||
position: absolute;
|
||||
bottom: 22px;
|
||||
left: 23px;
|
||||
z-index: 2;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 64px;
|
||||
color: white;
|
||||
line-height: 0.8;
|
||||
font-style: italic;
|
||||
letter-spacing: -4px;
|
||||
}
|
||||
.art-label {
|
||||
position: absolute;
|
||||
top: 21px;
|
||||
right: 22px;
|
||||
z-index: 2;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
text-align: right;
|
||||
letter-spacing: 1.4px;
|
||||
}
|
||||
.hero-card {
|
||||
position: absolute;
|
||||
right: 27px;
|
||||
top: 60px;
|
||||
width: 145px;
|
||||
height: 188px;
|
||||
border: 5px solid var(--surface);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 20px 30px #1b2a3530;
|
||||
transform: rotate(10deg);
|
||||
z-index: 3;
|
||||
}
|
||||
.hero-card img {
|
||||
width: 100%;
|
||||
height: 155px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero-card span {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 8px;
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-star {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 57px;
|
||||
color: var(--lime);
|
||||
font-size: 65px;
|
||||
line-height: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
@media (max-width: 1250px) {
|
||||
.hero-copy {
|
||||
padding: 28px;
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: 35px;
|
||||
}
|
||||
.hero-buttons {
|
||||
gap: 10px;
|
||||
}
|
||||
.hero-card {
|
||||
width: 125px;
|
||||
height: 164px;
|
||||
right: 18px;
|
||||
}
|
||||
.hero-card img {
|
||||
height: 131px;
|
||||
}
|
||||
.art-circle {
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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,72 +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';
|
||||
|
||||
| "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: '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: '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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { enterModal, leaveModal, topModal } from './modalLayers';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -11,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 {
|
||||
@@ -21,6 +25,8 @@
|
||||
onclose,
|
||||
footer,
|
||||
children,
|
||||
anchor = null,
|
||||
headerActions,
|
||||
}: Props = $props();
|
||||
|
||||
const FOCUSABLE =
|
||||
@@ -29,6 +35,29 @@
|
||||
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() {
|
||||
if (!dismissible) return;
|
||||
@@ -36,6 +65,7 @@
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!topModal(layer)) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
@@ -75,20 +105,21 @@
|
||||
if (!open) return;
|
||||
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
enterModal(layer);
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!panel || panel.contains(document.activeElement)) return;
|
||||
if (!topModal(layer) || !panel || panel.contains(document.activeElement))
|
||||
return;
|
||||
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? panel).focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
const wasTop = topModal(layer);
|
||||
leaveModal(layer);
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
if (invoker?.isConnected) invoker.focus();
|
||||
if (wasTop && invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
@@ -96,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}
|
||||
@@ -111,6 +152,7 @@
|
||||
{#if title}
|
||||
<h2 class="modal-title" id={titleId}>{title}</h2>
|
||||
{/if}
|
||||
{@render headerActions?.()}
|
||||
{#if dismissible}
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,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;
|
||||
|
||||
@@ -0,0 +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 = "";
|
||||
export function enterModal(token: symbol) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -23,27 +23,14 @@
|
||||
return t(`workbench.stage.${key}`);
|
||||
}
|
||||
|
||||
function charOf(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
script: '剧',
|
||||
style: '视',
|
||||
storyboard: '分',
|
||||
assets: '素',
|
||||
shot: '镜',
|
||||
};
|
||||
return map[key] ?? key;
|
||||
}
|
||||
|
||||
function avatarVar(key: string): string {
|
||||
const idx = ORDER.indexOf(key) + 1;
|
||||
return `var(--avatar-${idx})`;
|
||||
}
|
||||
|
||||
function stateOf(stage: WorkbenchStage): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
|
||||
function stateOf(
|
||||
stage: WorkbenchStage,
|
||||
): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
|
||||
if (stage.status === 'running') return 'running';
|
||||
if (stage.status === 'confirmed' && !stage.draft_edited) return 'confirmed';
|
||||
if (stage.status === 'confirmed' && stage.draft_edited) return 'edited';
|
||||
if (stage.status === 'awaiting_confirm') return stage.draft_edited ? 'edited' : 'draft';
|
||||
if (stage.status === 'awaiting_confirm')
|
||||
return stage.draft_edited ? 'edited' : 'draft';
|
||||
return 'todo';
|
||||
}
|
||||
|
||||
@@ -65,12 +52,13 @@
|
||||
</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}
|
||||
<li class="step">
|
||||
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}></span>{/if}
|
||||
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}
|
||||
></span>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
@@ -80,14 +68,19 @@
|
||||
data-state={state}
|
||||
onclick={() => onSelect(key)}
|
||||
aria-current={activeKey === key ? 'step' : undefined}
|
||||
title={open ? label(key) : t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
|
||||
title={open
|
||||
? label(key)
|
||||
: t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
|
||||
>
|
||||
<span class="avatar" style:background={avatarVar(key)}>{charOf(key)}</span>
|
||||
<span class="avatar"
|
||||
>{state === 'confirmed' ? '✓' : String(i + 1).padStart(2, '0')}</span
|
||||
>
|
||||
<span class="step-copy">
|
||||
<span class="step-name">{label(key)}</span>
|
||||
<span class="step-state st-{state}">
|
||||
{#if state === 'running'}<span class="breath" aria-hidden="true"></span>{/if}
|
||||
{stateLabel(stage!)}
|
||||
{#if state === 'running'}<span class="breath" aria-hidden="true"
|
||||
></span>{/if}
|
||||
{stage ? stateLabel(stage) : t('workbench.state.todo')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -238,4 +231,113 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.step {
|
||||
display: block;
|
||||
}
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
padding: 12px 10px;
|
||||
border: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
background: none !important;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 50%;
|
||||
font: 9px var(--mono);
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.step-btn.active {
|
||||
background: #e8eafe;
|
||||
box-shadow: none;
|
||||
}
|
||||
.step-btn.active .avatar {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.step-name {
|
||||
font-size: 11px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.step-state {
|
||||
font-size: 9px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.step-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-btn[data-state='confirmed'] .avatar {
|
||||
border-color: var(--ok);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 130px;
|
||||
}
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
border: 1px solid #ffffff10;
|
||||
border-radius: 10px;
|
||||
background: #12171c;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.step-btn.active {
|
||||
background: #00cae012;
|
||||
border-color: #00cae080;
|
||||
box-shadow: none;
|
||||
}
|
||||
.step-btn.active .avatar {
|
||||
background: #00cae012 !important;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.step-btn.active .step-name {
|
||||
color: #86e8f1;
|
||||
}
|
||||
.step-state {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.step-btn[data-state='confirmed'] .avatar {
|
||||
color: var(--ok);
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.step-btn:hover:not(.active) {
|
||||
background: #ffffff07;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user