Compare commits

..
Author SHA1 Message Date
总工andmultica-agent 48e16ae443 feat(studio): image result deletion and video history click-to-play (HEL-641)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 17:48:00 +08:00
施工员andmultica-agent 319c7b049b fix: nine review follow-ups for connections & workflow foundation (HEL-619)
1. .form-col border-box — 384px is the outer width (a6 spec)
2. import preview counts all analyzed inputs, matching inline/studio counts
3. SSRF guard also runs on endpoint save (create + patch)
4. schemeless/garbled URL is a 400 format error, not a DNS-classified probe failure
5. ComfyUI health probe timeout 120s → 5s (matches the test probe)
6. workflow delete refuses (409) while pending/running jobs use it; delete errors surfaced in the UI
7. agent_llm_assignments: per-role incremental merge server-side + single-role delta payloads
8. test_shell_tool now portable (interpreter on PATH; PYTHON+approval instead of ffprobe)
9. test_video_continue pauses the queue singleton via monkeypatch (no leak into test_workbench)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 16:13:00 +08:00
19 changed files with 451 additions and 112 deletions
@@ -13,6 +13,11 @@ from calliope.comfyui.registry import IMAGE_CLASSES, AUDIO_CLASSES, VIDEO_CLASSE
logger = logging.getLogger("calliope.comfyui")
# Reachability probe budget (HEL-619): matches the connections-page test
# probe (5s). Without it health() inherits the 120s client default, so a
# bad endpoint hangs every queued job for two minutes before failing.
HEALTH_TIMEOUT_SEC = 5.0
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
"""Raise with ComfyUI's own error body instead of an opaque status line.
@@ -76,7 +81,9 @@ class ComfyUIClient:
async def health(self) -> bool:
try:
resp = await self._http.get(f"{self.base_url}/system_stats")
resp = await self._http.get(
f"{self.base_url}/system_stats", timeout=HEALTH_TIMEOUT_SEC
)
return resp.status_code == 200
except Exception:
return False
@@ -77,8 +77,11 @@ def _parsed_url(base_url: str) -> tuple[str, str]:
url = (base_url or "").strip()
if not url:
raise HTTPException(status_code=400, detail="API 地址不能为空")
if "://" not in url:
url = f"http://{url}"
# HEL-619: the server validates format as strictly as the UI already
# does. A schemeless garbled string used to be silently prefixed with
# http:// and then reported as a DNS failure; it is a 400 format error.
if "://" not in url or any(ch.isspace() for ch in url):
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
@@ -135,7 +138,12 @@ async def list_endpoints() -> dict[str, Any]:
@router.post("/endpoints")
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
base_url, _host = _parsed_url(payload.base_url)
base_url, host = _parsed_url(payload.base_url)
# HEL-619: the same SSRF guard as the connectivity test applies on
# save — otherwise a blocked address can be stored and later probed by
# the worker path. Unresolvable hosts still pass (resolution happens
# again at probe time).
_assert_not_ssrf(host)
incoming = [ep for ep in settings.comfyui_endpoints or []]
incoming.append(payload.model_dump() | {"base_url": base_url})
try:
@@ -151,7 +159,8 @@ async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str,
_stored_endpoint(endpoint_id)
base_url = payload.base_url
if base_url is not None:
base_url, _host = _parsed_url(base_url)
base_url, host = _parsed_url(base_url)
_assert_not_ssrf(host) # same save-time guard as create (HEL-619)
patch = payload.model_dump(exclude_unset=True)
if base_url is not None:
patch["base_url"] = base_url
@@ -107,7 +107,11 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
if assignments_in is not None:
settings.ensure_llm_profiles()
ids = {p["id"] for p in settings.llm_profiles}
cleaned: dict[str, str | None] = {}
# HEL-619: per-role INCREMENTAL update. Rapid dropdown switches used
# to send whole cached snapshots that fully overwrote the map, so a
# stale snapshot could wipe a newer choice. Roles absent from the
# payload keep their stored value; a role sent as null is cleared.
cleaned: dict[str, str | None] = dict(settings.agent_llm_assignments or {})
for role, pid in assignments_in.items():
if role not in AGENT_LLM_ROLES:
continue
@@ -345,10 +345,29 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
conn = get_db(settings.db_path)
try:
cur = conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
conn.commit()
if cur.rowcount == 0:
# HEL-619: refuse to delete while queued/running jobs still use the
# workflow — deleting sets their workflow_id NULL and the job dies
# with "工作流已被删除" only after the user waits for it.
row = conn.execute(
"SELECT id FROM workflows WHERE id = ?", (workflow_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
pending = conn.execute(
"SELECT COUNT(*) AS n FROM jobs"
" WHERE workflow_id = ? AND status IN ('pending', 'running')",
(workflow_id,),
).fetchone()["n"]
if pending:
raise HTTPException(
status_code=409,
detail=(
f"该工作流还有 {pending} 个排队或执行中的任务,"
"请先等任务完成或取消任务,再删除该工作流"
),
)
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
conn.commit()
return {"ok": True}
finally:
conn.close()
@@ -374,6 +374,61 @@ def test_comfy_test_requires_url(client):
assert r.status_code == 400
def test_schemeless_url_is_format_error_everywhere(client):
"""HEL-619: a schemeless/garbled address is a 400 format error on every
entry point (probe + save), not a silent http:// prefix that later
surfaces as a DNS-classified probe failure."""
prev = _snapshot_settings()
try:
for url in ("192.168.0.1:8188", "http:192.168.0.1", "ht!tp://x", "http://a b.test/"):
r = client.post(
"/api/comfyui/endpoints/test", json={"base_url": url, "timeout_sec": 2}
)
assert r.status_code == 400, url
assert "http://" in r.json()["detail"] or "https://" in r.json()["detail"]
r = client.post(
"/api/comfyui/endpoints", json={"name": "无协议", "base_url": url}
)
assert r.status_code == 400, url
assert "无协议" not in [ep.get("name") for ep in settings.comfyui_endpoints or []]
finally:
for key, value in prev.items():
setattr(settings, key, value)
def test_save_endpoint_runs_ssrf_guard(client):
"""HEL-619: the SSRF guard applies on save (create + edit), not only on
the connectivity probe; loopback stays allowed."""
prev = _snapshot_settings()
try:
_clear_endpoints()
r = client.post(
"/api/comfyui/endpoints",
json={"name": "元数据", "base_url": "http://169.254.169.254/latest"},
)
assert r.status_code == 400
assert "禁止" in r.json()["detail"]
r = client.post(
"/api/comfyui/endpoints",
json={"name": "本机", "base_url": "http://127.0.0.1:8188"},
)
assert r.status_code == 200
eid = r.json()["endpoints"][-1]["id"]
r = client.patch(
f"/api/comfyui/endpoints/{eid}",
json={"base_url": "http://169.254.169.254/latest"},
)
assert r.status_code == 400
# the stored endpoint is untouched
stored = next(ep for ep in settings.comfyui_endpoints if ep["id"] == eid)
assert stored["base_url"] == "http://127.0.0.1:8188"
finally:
for key, value in prev.items():
setattr(settings, key, value)
# ----------------------------------------------------------------------
# workflow binding + job snapshots
# ----------------------------------------------------------------------
@@ -542,6 +597,41 @@ def test_queue_enqueue_stamps_all_paths(client):
setattr(settings, key, value)
def test_workflow_delete_blocked_while_jobs_pending(client):
"""HEL-619: deleting a workflow with pending/running jobs is refused
with 409 instead of silently nulling the jobs' workflow_id."""
from calliope.db import get_db
from calliope.queue.manager import queue_manager
wf = _import_workflow(client).json()
project = client.post(
"/api/projects", json={"title": "删除保护测试", "idea": "x"}
).json()
# Pause the lifespan-started worker so the pending job STAYS pending.
queue_manager.paused = True
try:
queue_manager.enqueue(
project_id=project["id"], kind="video", workflow_id=wf["id"], payload={}
)
r = client.delete(f"/api/workflows/{wf['id']}")
assert r.status_code == 409
assert "排队" in r.json()["detail"]
finally:
queue_manager.paused = False
# job finished → deletion allowed again
conn = get_db(settings.db_path)
try:
conn.execute("UPDATE jobs SET status = 'done' WHERE project_id = ?", (project["id"],))
conn.commit()
finally:
conn.close()
r = client.delete(f"/api/workflows/{wf['id']}")
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_settings_public_dict_masks_endpoint_tokens(client):
prev = _snapshot_settings()
try:
@@ -216,6 +216,18 @@ def test_settings_accepts_agent_llm_assignments(client):
# Unknown role keys are dropped, known roles stored (incl. explicit None)
assert body["agent_llm_assignments"] == {"video": a_id, "story": None}
# HEL-619: updates are per-role incremental — a partial payload must
# not wipe roles it does not mention (rapid-switch race).
r = client.post(
"/api/settings",
json={"agent_llm_assignments": {"main": a_id}},
)
assert r.status_code == 200
body = r.json()
assert body["agent_llm_assignments"]["main"] == a_id
assert body["agent_llm_assignments"]["video"] == a_id
assert body["agent_llm_assignments"]["story"] is None
r = client.post(
"/api/settings",
json={"agent_llm_assignments": {"video": "no-such-profile"}},
+54 -15
View File
@@ -13,6 +13,7 @@ The shell is default-OFF; every guard branch here is a security contract:
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
@@ -25,6 +26,23 @@ from calliope.agent.harness.registry import ToolContext
PYTHON = Path(sys.executable).stem # bare name ("python") — the tool refuses paths
@pytest.fixture(autouse=True, scope="module")
def _interpreter_on_path():
"""Make the bare interpreter name resolvable via PATH (HEL-619).
The guard resolves the command with ``shutil.which`` against PATH —
running pytest as ``path/to/.venv/bin/python -m pytest`` (or on a box
with no ``python`` alias) leaves the stem unresolvable and every
exec-path test fails for environmental reasons only. Prepending the
interpreter's own bin dir (restored afterwards) pins the lookup to the
interpreter actually running the suite, on every OS.
"""
prev = os.environ.get("PATH", "")
os.environ["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{prev}"
yield
os.environ["PATH"] = prev
@pytest.fixture(autouse=True, scope="module")
def _never_touch_real_db():
"""Redirect data_dir before ANY write in this module (repo test rule)."""
@@ -210,12 +228,19 @@ def test_structured_shell_approval_allows(enabled):
def test_cwd_outside_workspace_denied(enabled):
"""Guard denial tests use PYTHON + a structured approval card (not
ffprobe): the interpreter resolves on every machine, and the card
clears the approval gate so the LATER containment check is what fires
(HEL-619 — ffprobe-absent boxes used to fail these for environmental
reasons)."""
registry, _ = build_harness()
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "cwd": str(Path(settings_data_dir_home()))},
{"command": PYTHON, "cwd": str(settings_data_dir_home())},
)
)
assert out["reason_code"] == "guard_shell_outside_workspace"
@@ -230,11 +255,13 @@ def test_sensitive_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
secret = tmp_path / "calliope_config.json"
secret.write_text("{}", encoding="utf-8")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(secret)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(secret)]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -244,11 +271,13 @@ def test_db_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
db = tmp_path / "calliope.db"
db.write_bytes(b"")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(db)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(db)]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -256,11 +285,13 @@ def test_db_arg_denied(enabled, tmp_path):
def test_env_file_arg_denied(enabled):
registry, _ = build_harness()
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": ["--", ".env"]},
{"command": PYTHON, "args": ["-c", "print('x')", "--", ".env"]},
)
)
assert out["reason_code"] == "guard_shell_denied_path"
@@ -270,11 +301,13 @@ def test_outside_absolute_arg_denied(enabled, tmp_path):
registry, _ = build_harness()
outside = tmp_path / "movie.mp4"
outside.write_bytes(b"00")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": [str(outside)]},
{"command": PYTHON, "args": ["-c", "print('x')", str(outside)]},
)
)
assert out["reason_code"] == "guard_shell_outside_workspace"
@@ -287,16 +320,22 @@ def test_inside_asset_arg_allowed(enabled):
registry, _ = build_harness()
ref = settings.assets_dir / "ref.mp4"
ref.write_bytes(b"00")
sid = _session()
_answer_card(sid, "Yes, run it")
out = asyncio.run(
registry.execute(
_ctx(),
ToolContext(session_id=sid, project_id=None),
"run_command",
{"command": "ffprobe", "args": ["-v", "error", str(ref)]},
{
# The asset path is a real argv entry the guard must allow
# (and the interpreter actually reads it).
"command": PYTHON,
"args": ["-c", "import sys; print(open(sys.argv[1]).read())", str(ref)],
},
)
)
# Not a real mp4 — ffprobe fails, but the GUARD must not have denied it.
assert out.get("reason_code") is None
assert out.get("exit_code") != 0 or out.get("ok") is False or out["ok"] is True
assert out["ok"] is True, out
assert "00" in out["stdout"]
def test_workspace_relative_cwd_allowed(enabled, tmp_path):
@@ -289,7 +289,11 @@ def _stub_comfy(monkeypatch, captured: dict) -> list[str]:
prepare_media_inputs handed it; download_image writes out.mp4. Returns
the list that collects uploaded local paths.
"""
queue_manager.paused = True
# monkeypatch (not bare assignment): queue_manager is a process-level
# singleton — a bare `paused = True` leaks into every later test and
# stalls e.g. test_workbench's worker (HEL-619). monkeypatch restores
# the previous value at teardown.
monkeypatch.setattr(queue_manager, "paused", True)
uploads: list[str] = []
async def fake_upload_video(self, path, subfolder="calliope"):
@@ -706,6 +706,28 @@
const selectedEntry = $derived(
doneEntries.find((e) => e.key === selectedKey) ?? doneEntries[0] ?? null,
);
const selectedJobId = $derived(selectedEntry?.job.id ?? null);
/**
* Clicking a history row: a finished job loads into the preview
* (player / image), anything else just reports its live status —
* never a silent jump to the newest finished clip.
*/
function selectFromHistory(job: Job) {
const st = jobStatusInfo(job);
if (st.tone === 'ok' && job.output_paths.length) {
selectedKey = `${job.id}:${job.output_paths[0]}`;
return;
}
toast.info(st.detail && st.detail !== st.label ? `${st.label} · ${st.detail}` : st.label);
}
function histRowKey(e: KeyboardEvent, job: Job) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectFromHistory(job);
}
}
function summaryOf(job: Job): string {
const parts: string[] = [];
@@ -758,6 +780,26 @@
let curSec = $state(0);
let durSec = $state(0);
// Switching the selected video must stop the old clip and rewind it,
// never keep playing the previous audio/progress into the new one.
// Runs BEFORE the DOM swaps the src so the reset cannot clobber the
// duration/metadata events of the clip being loaded in.
$effect.pre(() => {
void selectedEntry?.key;
const v = videoEl;
if (v) {
try {
v.pause();
v.currentTime = 0;
} catch {
/* no loaded metadata yet — src change already reset it */
}
}
playing = false;
curSec = 0;
durSec = 0;
});
function fmtClock(s: number): string {
if (!Number.isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
@@ -785,13 +827,16 @@
else void playerBox?.requestFullscreen();
}
// First/last frame strip for the selected video job
// First/last frame + reference strip for the selected video job.
// Ref workflows come in two shapes: an autogrow image_list, or several
// plain image inputs — both are shown as reference images.
const selectedFrames = $derived.by(() => {
if (kind !== 'video' || !selectedEntry) return [];
const wf = ($workflowsQuery.data ?? []).find((w) => w.id === selectedEntry.job.workflow_id);
if (!wf) return [];
const vals = (selectedEntry.job.payload?.input_values ?? {}) as Record<string, unknown>;
const out: Array<{ label: string; path: string; ref?: boolean }> = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
const h = `${inp.role ?? ''} ${inp.label}`.toLowerCase();
const v = vals[inp.nodeId];
@@ -805,6 +850,10 @@
if (typeof v !== 'string' || !v) continue;
if (/first|start|首帧/.test(h)) out.push({ label: t('studio.slotFirst'), path: v });
else if (/last|end|尾帧/.test(h)) out.push({ label: t('studio.slotLast'), path: v });
else if (isMedia(inp) && inp.kind === 'image' && !/mask|遮罩/.test(h)) {
refIdx += 1;
out.push({ label: t('studio.slotRefN', { n: refIdx }), path: v, ref: true });
}
}
return out;
});
@@ -852,12 +901,31 @@
}
const deleteTarget = $derived(kindJobs.find((j) => j.id === deleteJobId) ?? null);
// The dialog names the exact job and — when it produced several
// outputs — says plainly that ALL of them go away with it (the
// backend only deletes whole jobs, never a single output file).
const deleteTitle = $derived(
deleteTarget ? t('studio.delTitleJob', { name: wfName(deleteTarget) }) : t('studio.delTitle'),
);
const deleteMessage = $derived.by(() => {
const n = deleteTarget?.output_paths?.length ?? 0;
return n > 1 ? t('studio.delConfirmMulti', { n }) : t('studio.delConfirm');
});
function askDeleteJob(jobId: number) {
deleteJobId = jobId;
deleteOpen = true;
}
async function doDelete() {
if (!deleteJobId) return;
try {
await playgroundApi.deleteJob(deleteJobId);
submittedIds.delete(deleteJobId);
if (selectedEntry?.job.id === deleteJobId) selectedKey = null;
toast.success(t('studio.deletedToast'));
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
@@ -1502,11 +1570,20 @@
<Icon name="upload" size={13} />
{t('studio.sendToImg2Img')}
</Button>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.download')}
</a>
</span>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.download')}
</a>
<Button
variant="danger"
size="sm"
title={t('studio.deleteBtn')}
onclick={() => askDeleteJob(selectedEntry!.job.id)}
>
<Icon name="trash" size={13} />
{t('studio.deleteBtn')}
</Button>
</span>
</div>
{/if}
{:else}
@@ -1521,7 +1598,8 @@
<h2>{t('studio.video.resultsTitle')}</h2>
{#if selectedEntry}
<span class="panel-hint">
{t('studio.lastDone', {
{wfName(selectedEntry.job)} ·
{t('studio.doneAt', {
time: fmtTime(parseDbTs(selectedEntry.job.completed_at ?? selectedEntry.job.created_at)),
})}
</span>
@@ -1594,11 +1672,20 @@
<Icon name="retry" size={13} />
{t('studio.retryLink')}
</Button>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.downloadMp4')}
</a>
</span>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.downloadMp4')}
</a>
<Button
variant="danger"
size="sm"
title={t('studio.deleteBtn')}
onclick={() => askDeleteJob(selectedEntry!.job.id)}
>
<Icon name="trash" size={13} />
{t('studio.deleteBtn')}
</Button>
</span>
</div>
{:else}
<p class="empty-line">{t('studio.historyEmptyVideo')}</p>
@@ -1619,59 +1706,73 @@
{kind === 'image' ? t('studio.historyEmptyImage') : t('studio.historyEmptyVideo')}
</p>
{:else}
<div class="history-list">
{#each kindJobs as job (job.id)}
{@const st = jobStatusInfo(job)}
<div class="hist-row" class:failed={job.status === 'failed'} class:video={kind === 'video'}>
<div class="hist-thumb" class:video={kind === 'video'}>
{#if job.output_paths?.[0]}
{#if kind === 'image'}
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
{:else}
<video
src={assetUrl(job.output_paths[0])}
preload="metadata"
muted
onloadedmetadata={(e) => noteDuration(job.output_paths![0], e)}
></video>
{#if durations[job.output_paths[0]]}
<span class="dur mono">{fmtClock(durations[job.output_paths[0]])}</span>
{/if}
{/if}
{:else if job.status === 'failed'}
<span class="thumb-fallback failed"><Icon name="alert" size={14} /></span>
<div class="history-list">
{#each kindJobs as job (job.id)}
{@const st = jobStatusInfo(job)}
<div
class="hist-row"
class:failed={job.status === 'failed'}
class:video={kind === 'video'}
class:selected={selectedJobId === job.id}
role="button"
tabindex={0}
aria-label={`${wfName(job)} · ${st.label}`}
aria-current={selectedJobId === job.id ? 'true' : undefined}
onclick={() => selectFromHistory(job)}
onkeydown={(e) => histRowKey(e, job)}
>
<div class="hist-thumb" class:video={kind === 'video'}>
{#if job.output_paths?.[0]}
{#if kind === 'image'}
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
{:else}
<span class="thumb-fallback"><Icon name={kind === 'image' ? 'image' : 'film'} size={14} /></span>
{/if}
</div>
<div class="hist-body">
<div class="hist-name" title={wfName(job)}>{wfName(job)}</div>
<div class="hist-meta mono">{summaryOf(job)}</div>
<div class="hist-status">
{#if job.status !== 'failed'}
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
{:else}
<button
type="button"
class="st fail-link"
disabled={retryingId === job.id}
onclick={() => retryJob(job)}
>
{t('studio.retryLink')}
</button>
<video
src={assetUrl(job.output_paths[0])}
preload="metadata"
muted
onloadedmetadata={(e) => noteDuration(job.output_paths![0], e)}
></video>
{#if durations[job.output_paths[0]]}
<span class="dur mono">{fmtClock(durations[job.output_paths[0]])}</span>
{/if}
{/if}
{:else if job.status === 'failed'}
<span class="thumb-fallback failed"><Icon name="alert" size={14} /></span>
{:else}
<span class="thumb-fallback"><Icon name={kind === 'image' ? 'image' : 'film'} size={14} /></span>
{/if}
</div>
<div class="hist-body">
<div class="hist-name" title={wfName(job)}>{wfName(job)}</div>
<div class="hist-meta mono">{summaryOf(job)}</div>
<div class="hist-status">
{#if job.status !== 'failed'}
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
{:else}
<button
type="button"
class="hist-del"
title={t('studio.deleteBtn')}
onclick={() => {
deleteJobId = job.id;
deleteOpen = true;
class="st fail-link"
disabled={retryingId === job.id}
onclick={(e) => {
e.stopPropagation();
retryJob(job);
}}
>
<Icon name="trash" size={13} />
{t('studio.retryLink')}
</button>
</div>
{/if}
<button
type="button"
class="hist-del"
title={t('studio.deleteBtn')}
onclick={(e) => {
e.stopPropagation();
askDeleteJob(job.id);
}}
>
<Icon name="trash" size={13} />
</button>
</div>
{#if job.status === 'failed' && job.error}
<div class="hist-error">{job.error}</div>
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
@@ -1711,9 +1812,10 @@
<ConfirmDialog
bind:open={deleteOpen}
title={t('studio.delTitle')}
message={t('studio.delConfirm')}
title={deleteTitle}
message={deleteMessage}
confirmLabel={t('studio.deleteBtn')}
cancelLabel={t('common.cancel')}
danger
onconfirm={doDelete}
oncancel={() => {
@@ -2295,10 +2397,23 @@
border-radius: 8px;
min-height: 44px;
align-items: center;
cursor: pointer;
}
.hist-row:hover {
background: var(--surface-2);
}
.hist-row:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
/* The row loaded into the preview stays marked, and its delete stays visible. */
.hist-row.selected {
background: var(--surface-2);
box-shadow: inset 2px 0 0 var(--accent);
}
.hist-row.selected .hist-del {
opacity: 1;
}
.hist-row.failed {
background: var(--error-dim);
}
@@ -2424,6 +2539,15 @@
.hist-row:hover .hist-del {
opacity: 1;
}
.hist-del:focus-visible {
opacity: 1;
}
/* Touch devices have no hover to reveal the delete affordance. */
@media (hover: none) {
.hist-del {
opacity: 1;
}
}
.hist-del:hover {
color: var(--error);
background: var(--error-dim);
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const de: Dict = {
'studio.image.historyTitle': 'Bildverlauf',
'studio.video.historyTitle': 'Videoverlauf',
'studio.historyCount': '{n} gesamt',
'studio.lastDone': 'Zuletzt fertig · {time}',
'studio.doneAt': 'Fertiggestellt um {time}',
'studio.kindImageText': 'Bild',
'studio.kindVideoText': 'Video',
'studio.loading': 'Lädt…',
@@ -1578,7 +1578,10 @@ export const de: Dict = {
'studio.cancelToast': 'Abbruch angefordert',
'studio.deleteBtn': 'Löschen',
'studio.delTitle': 'Aufgabeneintrag löschen',
'studio.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
'studio.delConfirm': 'Beim Löschen werden auch die Ergebnisdateien dieses Auftrags entfernt; der Verlauf ist nicht wiederherstellbar. Löschen?',
'studio.delTitleJob': 'Auftrag „{name}“ löschen',
'studio.delConfirmMulti': 'Dieser Auftrag hat {n} Ergebnisse. Beim Löschen werden der gesamte Auftragsdatensatz und alle {n} Ergebnisdateien entfernt; dies ist unwiderruflich. Löschen?',
'studio.deletedToast': 'Auftrag und zugehörige Ergebnisdateien gelöscht',
'studio.wfEmptyTitle': 'Noch keine {kind}-Workflows',
'studio.wfEmptyBody': 'Entwicklermodus in ComfyUI aktivieren, Workflow als API-JSON exportieren und auf der Seite ComfyUI-Verbindungen als {kind} importieren.',
'studio.wfEmptyGo': 'In ComfyUI-Verbindungen importieren',
+5 -2
View File
@@ -1461,7 +1461,7 @@ export const en = {
'studio.image.historyTitle': 'Image history',
'studio.video.historyTitle': 'Video history',
'studio.historyCount': '{n} total',
'studio.lastDone': 'Last done · {time}',
'studio.doneAt': 'Done at {time}',
'studio.kindImageText': 'image',
'studio.kindVideoText': 'video',
'studio.loading': 'Loading…',
@@ -1577,7 +1577,10 @@ export const en = {
'studio.cancelToast': 'Cancel requested',
'studio.deleteBtn': 'Delete',
'studio.delTitle': 'Delete task record',
'studio.delConfirm': 'The result files will be removed together with the record. Delete it?',
'studio.delConfirm': 'Deleting also removes this job’s result files; the history cannot be restored. Delete?',
'studio.delTitleJob': 'Delete job “{name}”',
'studio.delConfirmMulti': 'This job produced {n} results. Deleting removes the whole job record and all {n} result files; this cannot be undone. Delete?',
'studio.deletedToast': 'Job deleted together with its result files',
'studio.wfEmptyTitle': 'No {kind} workflows yet',
'studio.wfEmptyBody': "Enable ComfyUI's developer mode, export the workflow as API JSON, then import it on the ComfyUI Connections page as {kind}.",
'studio.wfEmptyGo': 'Import in ComfyUI Connections',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const es: Dict = {
'studio.image.historyTitle': 'Historial de imágenes',
'studio.video.historyTitle': 'Historial de vídeos',
'studio.historyCount': '{n} en total',
'studio.lastDone': 'Último · {time}',
'studio.doneAt': 'Completado a las {time}',
'studio.kindImageText': 'imagen',
'studio.kindVideoText': 'vídeo',
'studio.loading': 'Cargando…',
@@ -1578,7 +1578,10 @@ export const es: Dict = {
'studio.cancelToast': 'Cancelación solicitada',
'studio.deleteBtn': 'Eliminar',
'studio.delTitle': 'Eliminar registro de tarea',
'studio.delConfirm': 'Los archivos de resultados se eliminarán junto con el registro. ¿Eliminar?',
'studio.delConfirm': 'Al eliminar también se quitan los archivos de resultados de esta tarea; el historial no se puede restaurar. ¿Eliminar?',
'studio.delTitleJob': 'Eliminar tarea «{name}»',
'studio.delConfirmMulti': 'Esta tarea produjo {n} resultados. Al eliminarla se quita el registro completo y los {n} archivos de resultados; no se puede deshacer. ¿Eliminar?',
'studio.deletedToast': 'Tarea eliminada junto con sus archivos de resultados',
'studio.wfEmptyTitle': 'Aún no hay flujos de {kind}',
'studio.wfEmptyBody': 'Activa el modo desarrollador de ComfyUI, exporta el flujo como JSON API e impórtalo en Conexiones ComfyUI como {kind}.',
'studio.wfEmptyGo': 'Importar en Conexiones ComfyUI',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const fr: Dict = {
'studio.image.historyTitle': 'Historique images',
'studio.video.historyTitle': 'Historique vidéos',
'studio.historyCount': '{n} au total',
'studio.lastDone': 'Dernier · {time}',
'studio.doneAt': 'Terminé à {time}',
'studio.kindImageText': 'image',
'studio.kindVideoText': 'vidéo',
'studio.loading': 'Chargement…',
@@ -1578,7 +1578,10 @@ export const fr: Dict = {
'studio.cancelToast': 'Annulation demandée',
'studio.deleteBtn': 'Supprimer',
'studio.delTitle': 'Supprimer la tâche',
'studio.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
'studio.delConfirm': 'La suppression retire aussi les fichiers de résultats de cette tâche ; l’historique est irrécupérable. Supprimer ?',
'studio.delTitleJob': 'Supprimer la tâche « {name} »',
'studio.delConfirmMulti': 'Cette tâche a produit {n} résultats. La suppression retire la tâche entière et ses {n} fichiers de résultats ; action irréversible. Supprimer ?',
'studio.deletedToast': 'Tâche supprimée avec ses fichiers de résultats',
'studio.wfEmptyTitle': 'Pas encore de workflows {kind}',
'studio.wfEmptyBody': 'Activez le mode développeur de ComfyUI, exportez le workflow en JSON API, puis importez-le dans Connexions ComfyUI comme {kind}.',
'studio.wfEmptyGo': 'Importer dans Connexions ComfyUI',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const ja: Dict = {
'studio.image.historyTitle': '画像生成履歴',
'studio.video.historyTitle': '動画生成履歴',
'studio.historyCount': '計 {n} 回',
'studio.lastDone': '最終完了 · {time}',
'studio.doneAt': '完了 {time}',
'studio.kindImageText': '画像',
'studio.kindVideoText': '動画',
'studio.loading': '読み込み中…',
@@ -1578,7 +1578,10 @@ export const ja: Dict = {
'studio.cancelToast': 'キャンセルを要求しました',
'studio.deleteBtn': '削除',
'studio.delTitle': 'タスク記録を削除',
'studio.delConfirm': '結果ファイルも一緒に削除され、履歴は復元できません。削除しますか?',
'studio.delConfirm': '削除するとこのタスクの結果ファイルも削除され、履歴は元に戻せません。削除しますか?',
'studio.delTitleJob': 'タスク「{name}」を削除',
'studio.delConfirmMulti': 'このタスクには結果が {n} 件あります。削除するとタスク記録と {n} 件すべての結果ファイルが失われ、元に戻せません。削除しますか?',
'studio.deletedToast': 'タスクと結果ファイルを削除しました',
'studio.wfEmptyTitle': '{kind}ワークフローがまだありません',
'studio.wfEmptyBody': 'ComfyUI の開発者モードで API 形式 JSON を書き出し、「ComfyUI 接続」ページで{kind}として取り込んでください。',
'studio.wfEmptyGo': '「ComfyUI 接続」で取り込む',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const ko: Dict = {
'studio.image.historyTitle': '이미지 생성 기록',
'studio.video.historyTitle': '영상 생성 기록',
'studio.historyCount': '총 {n}회',
'studio.lastDone': '최근 완료 · {time}',
'studio.doneAt': '완료 {time}',
'studio.kindImageText': '이미지',
'studio.kindVideoText': '비디오',
'studio.loading': '불러오는 중…',
@@ -1578,7 +1578,10 @@ export const ko: Dict = {
'studio.cancelToast': '취소를 요청했습니다',
'studio.deleteBtn': '삭제',
'studio.delTitle': '작업 기록 삭제',
'studio.delConfirm': '결과 파일도 함께 삭제되며 기록은 복구할 수 없습니다. 삭제할까요?',
'studio.delConfirm': '삭제 시 이 작업의 결과 파일도 함께 제거되며 기록은 복구할 수 없습니다. 삭제할까요?',
'studio.delTitleJob': '작업 «{name}» 삭제',
'studio.delConfirmMulti': '이 작업에는 결과가 {n}개 있습니다. 삭제하면 작업 기록과 결과 파일 {n}개 모두 제거되며 복구할 수 없습니다. 삭제할까요?',
'studio.deletedToast': '작업과 결과 파일을 삭제했습니다',
'studio.wfEmptyTitle': '{kind} 워크플로가 아직 없습니다',
'studio.wfEmptyBody': 'ComfyUI 개발자 모드를 켜고 API 형식 JSON으로 내보낸 뒤, ComfyUI 연결 페이지에서 {kind}(으)로 가져오세요.',
'studio.wfEmptyGo': 'ComfyUI 연결에서 가져오기',
+4 -1
View File
@@ -1461,7 +1461,7 @@ export const zh: Dict = {
'studio.image.historyTitle': '生图历史',
'studio.video.historyTitle': '生视频历史',
'studio.historyCount': '共 {n} 次',
'studio.lastDone': '最近完成 · {time}',
'studio.doneAt': '完成于 {time}',
'studio.kindImageText': '图片',
'studio.kindVideoText': '视频',
'studio.loading': '正在加载…',
@@ -1579,6 +1579,9 @@ export const zh: Dict = {
'studio.deleteBtn': '删除',
'studio.delTitle': '删除任务记录',
'studio.delConfirm': '删除后该任务的结果文件会一并移除,历史不可恢复。确定删除?',
'studio.delTitleJob': '删除任务「{name}」',
'studio.delConfirmMulti': '该任务共生成 {n} 个结果,删除将移除整条任务记录及其全部 {n} 个结果文件,历史不可恢复。确定删除?',
'studio.deletedToast': '已删除该任务及其结果文件',
'studio.wfEmptyTitle': '还没有{kind}工作流',
'studio.wfEmptyBody': '在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON,再到「ComfyUI 连接」页导入并分类为{kind}。',
'studio.wfEmptyGo': '去「ComfyUI 连接」导入',
+5 -6
View File
@@ -18,7 +18,6 @@
type NamedAgent,
type SkillAdminEntry,
type SkillVersion,
type Settings,
canvasApi,
} from '$lib/api';
import { AGENT_TASK_PROMPTS, AGENT_TASK_TITLE_KEYS, isAgentTaskKind } from '$lib/agentTasks';
@@ -378,10 +377,10 @@
},
});
function setAgentModel(s: Settings, role: string, value: string) {
const merged: Record<string, string | null> = { ...(s.agent_llm_assignments ?? {}) };
merged[role] = value === '' ? null : value;
$assignMutation.mutate(merged);
function setAgentModel(role: string, value: string) {
// Single-role delta only (HEL-619): the whole-snapshot replay races
// concurrent switches; the server merges per role.
$assignMutation.mutate({ [role]: value === '' ? null : value });
}
// ─────────────────────────────────────────────────────────────────────
@@ -718,7 +717,7 @@
<select
class="a-select"
value={s.agent_llm_assignments?.[activeAgent.llm_role] ?? ''}
onchange={(e) => setAgentModel(s, activeAgent.llm_role, e.currentTarget.value)}
onchange={(e) => setAgentModel(activeAgent.llm_role, e.currentTarget.value)}
>
<option value="">{t('agentsPage.modelDefault')}</option>
{#each s.llm_profiles as p (p.id)}
@@ -460,9 +460,10 @@
});
const wfParamPreview = $derived.by(() => {
if (!wfAnalysis) return [];
return wfAnalysis.inputs
.filter((i) => !['image', 'image_list', 'image_url', 'video', 'audio'].includes(i.kind))
.map((i) => i.label);
// Count EVERY analyzed input (media slots included) so the preview
// matches the inline count shown after import (input_schema.length)
// and in the studio — HEL-619.
return wfAnalysis.inputs.map((i) => i.label);
});
const wfImportMutation = createMutation({
@@ -635,6 +636,10 @@
client.invalidateQueries({ queryKey: ['workflows'] });
toast.success(t('conn.wfDeleted'));
},
onError: (err) => {
// e.g. 409 "该工作流还有 N 个排队或执行中的任务…" (HEL-619)
toast.error(apiErrorMessage(err));
},
});
// ------------------------------------------------------------------
@@ -2067,6 +2072,8 @@
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px 20px;
/* keep the 384px side-col the OUTER width (HEL-619) */
box-sizing: border-box;
}
.layout:not(.form-open) .form-col {
display: none;
+9 -5
View File
@@ -420,10 +420,11 @@
},
});
function setAssignment(s: Settings, role: string, value: string) {
const merged: Record<string, string | null> = { ...(s.agent_llm_assignments ?? {}) };
merged[role] = value === '' ? null : value;
$assignMutation.mutate(merged);
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 });
}
// ------------------------------------------------------------------
@@ -630,7 +631,7 @@
<select
class="a-select"
value={s.agent_llm_assignments?.[agent.role] ?? ''}
onchange={(e) => setAssignment(s, agent.role, e.currentTarget.value)}
onchange={(e) => setAssignment(agent.role, e.currentTarget.value)}
>
<option value="">{t('models.agentDefault')}</option>
{#each profiles as p (p.id)}
@@ -1195,6 +1196,9 @@
padding: 18px 20px 20px;
position: sticky;
top: 24px;
/* 384px must be the OUTER width per the a6 spec — without
border-box padding+border inflate it to 426px (HEL-619). */
box-sizing: border-box;
}
.form-idle {
display: flex;