HEL-665: implement v5 creation hierarchy and project-bound agent drafts

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
leefer
2026-10-15 00:33:26 +08:00
co-authored by multica-agent
parent 012a946f85
commit 35fd07af24
22 changed files with 2818 additions and 1082 deletions
@@ -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)
+33
View File
@@ -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
# ─────────────────────────────────────────────────────────────────────────
+6
View File
@@ -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: (
@@ -0,0 +1,661 @@
<script lang="ts">
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;
}
let {
main = false,
comparePath = null,
tab = $bindable(),
job,
wf,
nowMs,
open,
inactive,
canSendToSlot,
crossfade,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onCancel,
onSendToSlot,
onDelete,
}: Props = $props();
let detailsOpen = $state(false);
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
lightbox = $state(false);
$effect(() => {
void job?.id;
detailsOpen = 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 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) ?? '',
);
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('');
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
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="生成结果">
<header class="artboard-toolbar">
<div class="result-title">
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
</div>
<div class="canvas-actions">
{#if output && !video}<button
class="canvas-btn"
onclick={() => (lightbox = true)}
aria-label="放大查看">⤢</button
>
{#if compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => (compare = !compare)}
>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
{/if}
{#if output}<a
class="canvas-btn"
href={assetUrl(output)}
download
aria-label="下载生成结果">↓ 下载</a
>{/if}
<button class="canvas-btn" onclick={() => (detailsOpen = true)}
>详情与操作</button
>
</div>
</header>
<div class="stage" class:comparing={compare}>
{#if job && st}{#key mediaKey}
<div class="stage-media">
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
</div>
{:else if output}
<div class="print a-photo">
{#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="本次生成的作品"
>
{#each outputs as p, i}<button
aria-pressed={output === p}
onclick={() => (outIndex = i)}
aria-label={'查看第 ' + (i + 1) + ' 件作品'}>{i + 1}</button
>{/each}
</div>{/if}
</div>
<footer class="artboard-footer">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
>
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
</footer>
</div>
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
{#if job && st}
<div class="detail-actions">
<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={() => {
onDelete(job);
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">
{#each spec as s}<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>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
onClose={() => (lightbox = false)}
/>{/if}
<style>
.artboard {
display: grid;
grid-template-rows: 38px minmax(0, 1fr) 32px;
height: 100%;
min-height: 0;
border: 1px solid #ffffff0d;
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
overflow: hidden;
}
.artboard-toolbar,
.artboard-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
min-width: 0;
}
.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-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.canvas-btn {
background: #ffffff04;
color: var(--text-2);
border: 1px solid #ffffff0a;
border-radius: 6px;
padding: 5px 8px;
font-size: 10px;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.canvas-btn:hover {
color: var(--text);
background: #ffffff0a;
}
.canvas-btn[aria-pressed='true'],
.play-button {
color: var(--accent);
border-color: var(--accent-line);
}
.stage {
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
}
.stage-media {
position: absolute;
inset: 0;
padding: 8px 20px;
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;
}
.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: #17171d;
pointer-events: none;
}
.comparison-divider {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent);
pointer-events: none;
}
.artboard-footer {
justify-content: flex-start;
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: #101015d9;
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>
@@ -14,7 +14,9 @@
onPick,
onKind,
onDetails,
disabled = false,
}: {
disabled?: boolean;
kind: 'image' | 'video';
metas: WorkflowMeta[];
selectedId: number | null;
@@ -92,17 +94,18 @@
<aside class="workflow-rail" aria-label="创作工作流列表">
<header>
<i>✧</i>
<div><strong>AIGC工作台</strong><small>选择工作流,开始创作</small></div>
<div><strong>创作台</strong><small>选择工作流,开始创作</small></div>
</header>
<div class="rail-tools">
<div class="kind-tabs" role="tablist" aria-label="创作类型">
<button
role="tab"
{disabled}
aria-selected={kind === 'image'}
onclick={() => onKind('image')}>图片</button
><button
role="tab"
{disabled}
aria-selected={kind === 'video'}
onclick={() => onKind('video')}>视频</button
>
@@ -139,6 +142,7 @@
>
<button
class="workflow-pick"
{disabled}
onclick={() => onPick(m.id)}
aria-pressed={selectedId === m.id}
title={m.name}
@@ -186,298 +190,251 @@
<style>
.workflow-rail {
width: 263px;
width: 232px;
flex-shrink: 0;
background: linear-gradient(#141419, #0f0f12);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
min-height: 0;
background: #121216;
border-right: 1px solid var(--line);
}
.workflow-rail > header {
height: 76px;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 17px;
padding: 21px 18px 16px;
border-bottom: 1px solid var(--line);
background: radial-gradient(at 18% 42%, #00cae026, transparent 65%);
flex-shrink: 0;
}
header i {
height: 39px;
width: 39px;
border: 1px solid #00cae08c;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 22px;
font-style: normal;
color: var(--accent);
box-shadow: 0 0 22px #00cae025;
}
header strong {
font-size: 17px;
font-size: 18px;
font-weight: 600;
display: block;
color: var(--accent);
}
header small {
font-size: 10px;
color: var(--text-3);
display: block;
margin-top: 2px;
font-size: 11px;
color: var(--text-3);
margin-top: 6px;
}
.rail-tools {
padding: 14px 11px 8px;
padding: 14px 12px 7px;
}
.kind-tabs {
display: flex;
gap: 4px;
padding: 4px;
background: #09090b;
border-radius: 7px;
gap: 3px;
background: #09090c;
border: 1px solid var(--line);
border-radius: 9px;
padding: 3px;
}
.kind-tabs button {
flex: 1;
border: 0;
padding: 7px 3px;
background: none;
border-radius: 6px;
padding: 7px;
color: #888c92;
color: var(--text-3);
font-size: 12px;
border-radius: 6px;
cursor: pointer;
transition:
background-color 180ms ease,
color 180ms ease;
}
.kind-tabs [aria-selected='true'] {
background: linear-gradient(135deg, #00cae0, #00a8b8);
color: white;
box-shadow: 0 2px 8px #00cae04d;
color: #062f35;
background: var(--accent);
font-weight: 600;
}
.rail-tools input {
width: 100%;
border: 1px solid #fbbf2473;
border-radius: 7px;
background: #fbbf2414;
margin-top: 10px;
font-size: 11px;
padding: 7px 10px;
margin-top: 11px;
padding: 9px 10px;
background: #1d1b16;
border: 1px solid #f5b04228;
border-radius: 8px;
color: var(--text);
}
.filters {
display: flex;
gap: 7px;
gap: 5px;
margin-top: 8px;
}
.filters select {
width: 50%;
min-width: 0;
border: 1px solid var(--line);
background: var(--bg-3);
border-radius: 5px;
padding: 5px;
font-size: 10px;
color: var(--text-3);
border: 1px solid var(--line);
background: #1b1c21;
border-radius: 5px;
padding: 5px 3px;
}
.list-meta {
display: flex;
justify-content: space-between;
padding: 5px 15px 8px;
color: #6f777e;
padding: 8px 16px;
font-size: 10px;
color: var(--text-3);
}
.list-meta button {
border: 0;
background: none;
color: var(--text-3);
font-size: 10px;
color: #8c99a0;
cursor: pointer;
}
.list-meta button[aria-pressed='true'] {
color: #f5b042;
color: var(--warn);
}
.rail-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0 11px 8px;
scrollbar-width: thin;
scrollbar-color: #34363d transparent;
padding: 0 8px 8px;
scrollbar-gutter: stable;
}
.workflow-row {
border-radius: 11.25px;
position: relative;
margin: 5px 0;
border: 1px solid transparent;
transition:
background-color 180ms ease,
border-color 180ms ease;
position: relative;
display: flex;
align-items: center;
border-radius: 10px;
margin: 2px 0;
}
.workflow-row.selected {
background: #ffffff26;
border-color: #00cae039;
background: linear-gradient(120deg, #00cae016, #00cae003);
}
.workflow-row.selected::before {
content: '';
width: 3px;
height: 22px;
position: absolute;
left: 0;
background: var(--accent);
border-radius: 3px;
}
.workflow-pick {
display: flex;
gap: 11px;
width: 100%;
align-items: center;
gap: 9px;
padding: 9px 8px;
min-height: 55px;
width: 100%;
min-width: 0;
text-align: left;
background: none;
border: 0;
padding: 13px 8px;
background: none;
color: var(--text);
cursor: pointer;
min-height: 75px;
}
.workflow-pick i {
font-size: 22px;
font-style: normal;
display: grid;
place-items: center;
width: 39px;
height: 39px;
border-radius: 8px;
background: #27c0a210;
color: #27c0a2;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.workflow-pick i.video {
background: #fa347110;
color: #fa3471;
border-radius: 9px;
border: 1px solid #00cae01f;
color: var(--accent);
background: #00cae008;
font-size: 16px;
font-style: normal;
}
.workflow-pick > span {
min-width: 0;
padding-right: 8px;
flex: 1;
}
.workflow-pick b {
font-size: 12.1875px;
line-height: 1.5;
font-weight: 600;
display: block;
font-size: 12px;
font-weight: 550;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
}
.workflow-pick small {
font-size: 10.3125px;
line-height: 1.5;
color: #ffffff99;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
display: block;
font-size: 10px;
white-space: nowrap;
overflow: hidden;
margin-top: 4px;
text-overflow: ellipsis;
color: var(--text-3);
}
.row-actions {
position: absolute;
right: 4px;
top: 3px;
display: flex;
gap: 3px;
opacity: 0;
transition: opacity 180ms ease;
gap: 2px;
padding-right: 4px;
flex-shrink: 0;
}
.row-actions button {
font-size: 10px;
border: 1px solid #ffffff22;
background: #111827;
color: #a9b1bd;
border-radius: 50%;
width: 19px;
height: 19px;
opacity: 0;
padding: 2px;
height: 24px;
width: 20px;
border: 0;
color: var(--text-3);
background: none;
font-size: 12px;
cursor: pointer;
padding: 0;
}
.row-actions .pressed,
.row-actions button[aria-pressed='true'] {
color: #ffd17c;
border-color: #f5b04280;
}
.workflow-row:focus-within .row-actions,
.workflow-row:hover .row-actions,
.workflow-row:has(.pressed) .row-actions {
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: #ffffff05;
}
.empty {
color: var(--text-3);
font-size: 11px;
padding: 12px;
}
.more {
width: 100%;
font-size: 11px;
padding: 10px;
background: #ffffff04;
padding: 8px;
background: none;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text-3);
cursor: pointer;
}
.empty {
border-radius: 6px;
font-size: 11px;
color: var(--text-3);
padding: 12px;
line-height: 1.8;
cursor: pointer;
}
footer {
padding: 12px 11px;
padding: 12px;
display: grid;
gap: 6px;
border-top: 1px solid var(--line);
flex-shrink: 0;
}
footer a,
.manage {
font-size: 11px;
color: var(--text-3);
text-decoration: none;
}
.manage {
display: flex;
gap: 10px;
align-items: center;
text-align: left;
width: 100%;
border: 1px solid #ffd70066;
background: linear-gradient(135deg, #ffd7001f, #f7931a0a);
border-radius: 11.25px;
padding: 11px;
font-size: 11px;
color: var(--text);
display: flex;
align-items: center;
gap: 7px;
background: none;
border: 0;
padding: 7px 8px;
cursor: pointer;
}
.manage > span {
display: grid;
place-items: center;
width: 31px;
height: 31px;
border-radius: 50%;
border: 1px solid #ffd70080;
color: #ffd700;
font-size: 20px;
}
.manage small {
display: block;
font-size: 9px;
color: #8b8470;
margin-top: 3px;
display: none;
}
.manage b {
margin-left: auto;
color: #ffd700;
}
footer > a {
font-size: 10px;
color: var(--text-3);
display: block;
text-decoration: none;
margin: 9px 0 0 9px;
button:disabled {
opacity: 0.5;
cursor: wait;
}
@media (hover: hover) and (pointer: fine) {
.workflow-row:hover {
background-color: #ffffff0a;
border-color: #ffffff12;
}
.workflow-row.selected:hover {
background-color: #ffffff26;
}
.row-actions button:hover {
color: #ffd17c;
border-color: #f5b04280;
}
}
@media (max-width: 1200px) {
@media (max-width: 1100px) {
.workflow-rail {
width: 240px;
width: 210px;
}
}
</style>
@@ -10,10 +10,9 @@
import AppShell from '$lib/components/shell/AppShell.svelte';
import Composer from '$lib/components/generate/Composer.svelte';
import ContextBar from '$lib/components/generate/ContextBar.svelte';
import Inspector from '$lib/components/generate/Inspector.svelte';
import Artboard from './Artboard.svelte';
import ParamsPanel from '$lib/components/generate/ParamsPanel.svelte';
import TakeFeed from '$lib/components/generate/TakeFeed.svelte';
import ResultRail from './ResultRail.svelte';
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
import {
readDeskContext,
@@ -65,7 +64,7 @@
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
/** Refract creative studio. The large artboard and right composer share
/** Creative studio. The artboard and fixed input dock share
* existing workflow/schema/job logic. Tool selection stays within the workspace;
* project context and explicit result attachment stay server-validated. */
interface Props {
@@ -281,8 +280,12 @@
);
}
let actionRevision = 0;
function pickWorkflow(id: number) {
bayOpen = false;
if (slotBusy || submitting) return;
actionRevision++;
selectedJobId = workflowResults.get(id) ?? null;
if (id !== workflowId) workflowId = id;
void tick().then(() => promptEl?.focus());
}
@@ -370,6 +373,23 @@
}
}
let pendingDraft = untrack(readDraft);
const workflowDrafts = new SvelteMap<number, FormValues>();
const workflowEndpoints = new SvelteMap<number, string>();
const workflowResults = new SvelteMap<number, number>();
try {
const saved = JSON.parse(
window.sessionStorage.getItem(draftKey + ':workflows') ?? 'null',
);
if (saved?.source === sourceFingerprint) {
for (const [id, draft] of Object.entries(saved.drafts ?? {}))
if (Number.isInteger(Number(id)) && draft && typeof draft === 'object')
workflowDrafts.set(Number(id), draft as FormValues);
for (const [id, ep] of Object.entries(saved.endpoints ?? {}))
if (typeof ep === 'string') workflowEndpoints.set(Number(id), ep);
}
} catch {
/* The form works without storage. */
}
const restoredWorkflowId = pendingDraft?.workflowId ?? null;
let workflowId = $state<number | null>(pendingDraft?.workflowId ?? null);
let values = $state<FormValues>({});
@@ -378,6 +398,7 @@
let sent = $state(false);
let slotBusy = $state(false);
let promptEl = $state<HTMLTextAreaElement | null>(null);
let composer = $state<Composer | null>(null);
let pickerFor = $state<string | null>(null);
const currentWorkflow = $derived(
@@ -413,45 +434,6 @@
workflowId = pick?.id ?? null;
});
/**
* New workflow → its own defaults, keeping what the user already gave:
* the prompt / negative prompt (by role) and uploaded images, poured
* into the new workflow's image slots in order.
*/
function carryOver(
prev: Workflow | null,
prevValues: FormValues,
next: Workflow,
): FormValues {
const out = schemaDefaults(next.input_schema ?? []);
const nextDs = classifySchema(next.input_schema ?? [], next.kind);
if (!prev) return out;
const prevDs = classifySchema(prev.input_schema ?? [], prev.kind);
for (const [a, b] of [
[prevDs.prompt, nextDs.prompt],
[prevDs.negative, nextDs.negative],
]) {
const v = a ? prevValues[a.nodeId] : undefined;
if (a && b && typeof v === 'string' && v.trim()) out[b.nodeId] = v;
}
const images: string[] = [];
for (const s of prevDs.slots) {
const v = prevValues[s.inp.nodeId];
if (s.mediaKind === 'image' && !s.mask && typeof v === 'string' && v)
images.push(v);
}
for (const g of prevDs.lists)
if (g.kind === 'image_list') images.push(...listPaths(prevValues, g));
for (const s of nextDs.slots) {
if (s.mediaKind === 'image' && !s.mask && images.length)
out[s.inp.nodeId] = images.shift()!;
}
const list = nextDs.lists.find((g) => g.kind === 'image_list');
if (list && images.length)
out[list.nodeId] = images.slice(0, list.max ?? 9);
return out;
}
let lastWf = $state<Workflow | null>(null);
let ctxApplied = false;
$effect(() => {
@@ -466,7 +448,18 @@
return;
}
if (wf.id === lastWf?.id) return;
let next = carryOver(lastWf, values, wf);
if (lastWf) {
workflowDrafts.set(lastWf.id, structuredClone($state.snapshot(values)));
workflowEndpoints.set(lastWf.id, endpointId);
}
let next = {
...schemaDefaults(wf.input_schema ?? []),
...(workflowDrafts.get(wf.id) ?? {}),
};
endpointTouched = true;
endpointId =
workflowEndpoints.get(wf.id) ?? wf.endpoint_id ?? activeId ?? '';
selectedJobId = workflowResults.get(wf.id) ?? null;
if (!ctxApplied && ctx) {
ctxApplied = true;
next = applyContext(wf, next);
@@ -484,20 +477,33 @@
});
$effect(() => {
const wf = currentWorkflow,
inputs = values;
inputs = values,
ep = endpointId;
if (!wf || lastWf?.id !== wf.id) return;
try {
window.sessionStorage.setItem(
draftKey,
JSON.stringify({
workflowId: wf.id,
values: inputs,
source: sourceFingerprint,
}),
);
} catch {
/* Storage may be unavailable; the current form still works. */
}
untrack(() => {
try {
workflowDrafts.set(wf.id, structuredClone($state.snapshot(inputs)));
workflowEndpoints.set(wf.id, ep);
window.sessionStorage.setItem(
draftKey + ':workflows',
JSON.stringify({
source: sourceFingerprint,
drafts: Object.fromEntries(workflowDrafts),
endpoints: Object.fromEntries(workflowEndpoints),
}),
);
window.sessionStorage.setItem(
draftKey,
JSON.stringify({
workflowId: wf.id,
values: inputs,
source: sourceFingerprint,
}),
);
} catch {
/* Storage may be unavailable; the current form still works. */
}
});
});
function applyContext(wf: Workflow, base: FormValues): FormValues {
@@ -623,6 +629,10 @@
showErrors = true;
const b = block;
if (!b) return;
if (b.target?.startsWith('slot-')) {
composer?.openMedia();
await tick();
}
if (b.params) {
openParams();
return;
@@ -671,7 +681,8 @@
...(wbSendContext ? { context: wbSendContext } : {}),
});
submittedIds.add(res.job.id);
selectedJobId = res.job.id;
workflowResults.set(wf.id, res.job.id);
if (workflowId === wf.id) selectedJobId = res.job.id;
if (!matchesFilter(res.job, filter)) filter = 'all';
showErrors = false;
sent = true;
@@ -740,7 +751,8 @@
allJobs
.filter(
(j) =>
matchesFilter(j, filter) &&
j.workflow_id === workflowId &&
j.kind === kind &&
!pendingIds.has(j.id) &&
jobStatusInfo(j).tone === 'ok',
)
@@ -755,6 +767,8 @@
function selectTake(job: Job, via: 'pointer' | 'keyboard' = 'pointer') {
const from = via === 'pointer' ? (feed?.markRect() ?? null) : null;
selectedJobId = job.id;
if (workflowId != null && job.workflow_id === workflowId)
workflowResults.set(workflowId, job.id);
tab = 'result';
crossfade = via === 'pointer';
if (via === 'pointer') {
@@ -827,6 +841,7 @@
window.dispatchEvent(new CustomEvent('refract-focus', { detail: false })),
);
function mediumLink(next: DeskKind) {
if (slotBusy || submitting) return;
const url = new URL(page.url);
url.pathname = '/' + next;
url.searchParams.delete('wf');
@@ -862,6 +877,8 @@
/** Fill the composer back with a past take's values (on its own workflow). */
async function reuse(job: Job) {
if (slotBusy || submitting) return;
const revision = ++actionRevision;
const id = job.workflow_id;
if (id == null) {
toast.error(t('studio.reuseMissingWf'));
@@ -880,7 +897,17 @@
else if (Array.isArray(v) && v.every((p) => typeof p === 'string'))
next[k] = v as string[];
}
if (slotBusy || submitting || revision !== actionRevision) return;
if (lastWf) workflowEndpoints.set(lastWf.id, endpointId);
endpointTouched = true;
endpointId =
workflowEndpoints.get(wf.id) ?? wf.endpoint_id ?? activeId ?? '';
if (lastWf && lastWf.id !== wf.id)
workflowDrafts.set(lastWf.id, structuredClone($state.snapshot(values)));
workflowDrafts.set(wf.id, next);
workflowId = wf.id;
selectedJobId = job.id;
workflowResults.set(wf.id, job.id);
lastWf = wf;
values = next;
showErrors = false;
@@ -1086,17 +1113,24 @@
onPick={pickWorkflow}
onKind={mediumLink}
onDetails={openBay}
disabled={slotBusy || submitting}
/>
<div class="refract-studio">
<header class="studio-heading">
<div class="heading-actions">
<button class="a-btn gold sm" onclick={() => (historyOpen = true)}
>◷ 任务列表</button
<button class="a-btn ghost sm" onclick={() => (historyOpen = true)}
>◷ 生成历史</button
><button id="desk-wf" class="model-selector" onclick={openBay}
>{currentWorkflow?.name ?? currentMeta?.name ?? '选择工作流'} ⌄</button
>
</div>
<div class="heading-actions">
{#if selectedJob && workbenchCtxOf(selectedJob) && selectedJob.status === 'done'}<button
class="a-btn secondary sm"
disabled={attachBusyId === selectedJob.id}
onclick={() => selectedJob && attachToProject(selectedJob)}
>保存到来源项目 ↗</button
>{/if}
<button class="a-btn secondary sm" onclick={openParams}
>创作设置</button
><button class="a-btn ghost sm" onclick={toggleInspector}
@@ -1107,15 +1141,8 @@
{@render ctxBanner()}
<div class="studio-layout">
<section class="viewer" aria-label="查看生成结果">
<div class="viewer-head">
<span>生成结果</span><span
>{selectedJob
? '第 ' + selectedJob.id + ' 次生成'
: '等待你的第一件作品'}</span
>
</div>
<div class="preview-area">
{#if selectedJob}<Inspector
{#if selectedJob}<Artboard
main
comparePath={allJobs.find(
(j) =>
@@ -1157,25 +1184,11 @@
<small>生成结果在这里显示,视频由你手动播放。</small>
</div>
</div>{/if}
{#if selectedJob && workbenchCtxOf(selectedJob) && selectedJob.status === 'done'}<button
class="a-btn secondary save-project"
disabled={attachBusyId === selectedJob.id}
onclick={() => selectedJob && attachToProject(selectedJob)}
>保存到来源项目 ↗</button
>{/if}
</div>
<ResultRail
nameOf={(job) => workflowOf(job)?.name ?? '工具已删除'}
jobs={allJobs.filter(
(j) => j.kind === kind && !pendingIds.has(j.id),
)}
selectedId={selectedJob?.id ?? null}
onSelect={(job) => selectTake(job, 'pointer')}
onHistory={() => (historyOpen = true)}
/>
</section>
<aside class="studio-form" aria-label="提示词、素材与参数">
<Composer
bind:this={composer}
dock
{ds}
{values}
@@ -1371,89 +1384,179 @@
</Modal>
<style>
.refract-studio {
padding: 16px 22px;
height: 100%;
min-height: 0;
.reference-desk {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.focused :global(.workflow-rail) {
display: none;
}
.refract-studio {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
box-sizing: border-box;
gap: 12px;
padding: 0 16px 14px;
}
.studio-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
gap: 8px;
height: 44px;
flex-shrink: 0;
}
h1 {
font-size: 22px;
margin: 0 0 4px;
font-weight: 650;
}
.heading-actions {
display: flex;
gap: 8px;
align-items: center;
}
.studio-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 18px;
}
.studio-form {
min-width: 0;
min-height: 0;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
overflow: hidden;
}
.viewer {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.viewer-head {
font-size: 12px;
display: flex;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
color: var(--text-3);
}
.viewer-head span:first-child {
font-weight: 650;
color: var(--text);
}
.preview-area {
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.save-project {
flex-shrink: 0;
margin: 0;
min-width: 0;
}
.model-selector {
border: 0;
background: none;
color: var(--text-2);
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-heading :global(.a-btn) {
min-height: 28px;
font-size: 11px;
padding: 4px 8px;
}
.studio-layout {
display: grid;
grid-template-rows: minmax(0, 1fr) 190px;
gap: 12px;
flex: 1;
min-height: 0;
min-width: 0;
}
.viewer,
.preview-area {
min-height: 0;
height: 100%;
min-width: 0;
overflow: hidden;
}
.studio-form {
min-height: 0;
min-width: 0;
}
.desk-welcome {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
gap: 8px;
text-align: left;
width: 100%;
gap: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
}
.desk-welcome h1 {
font-size: 24px;
font-weight: 550;
margin: 0;
}
.desk-emblem {
color: var(--accent);
font-size: 56px;
line-height: 1.2;
}
.eyebrow {
color: var(--accent);
font-size: 10px;
}
.workflow-description {
max-width: 400px;
padding: 0 20px;
}
.workflow-description strong {
font-size: 13px;
color: var(--text-2);
}
.workflow-description p,
.workflow-description small {
font-size: 11px;
color: var(--text-3);
}
.history-full {
height: min(680px, 68vh);
}
.param-actions {
display: flex;
align-items: center;
gap: 10px;
}
.param-actions span {
margin-right: auto;
font-size: 11px;
color: var(--text-3);
}
.runtime-setting {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
font-size: 12px;
}
.runtime-setting select {
max-width: 300px;
}
.runtime-setting a {
color: var(--accent);
}
.picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.picker-item {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
padding: 12px;
padding: 0;
overflow: hidden;
background: var(--surface);
cursor: pointer;
color: var(--text);
}
.picker-item img {
width: 100%;
height: 120px;
object-fit: cover;
}
.picker-empty {
color: var(--text-3);
}
@media (max-height: 740px) {
.studio-layout {
grid-template-rows: minmax(0, 1fr) 172px;
gap: 10px;
}
.refract-studio {
padding-bottom: 10px;
}
}
@media (max-width: 1100px) {
.refract-studio {
padding-left: 12px;
padding-right: 12px;
}
.model-selector {
max-width: 200px;
}
}
.tool-picker {
min-height: 0;
@@ -1571,261 +1674,4 @@
font-size: 12px;
color: var(--text-3);
}
@media (max-width: 1200px) {
.refract-studio {
padding: 12px 16px;
}
.studio-layout {
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
}
.tool-picker-content {
grid-template-columns: minmax(0, 1fr) 230px;
}
.tool-detail-scroll {
padding: 14px;
}
}
@media (max-width: 1000px) {
.refract-studio {
padding: 10px;
}
.studio-layout {
grid-template-columns: 290px minmax(0, 1fr);
gap: 10px;
}
.tool-list {
padding-right: 8px;
}
.tool-picker-content {
grid-template-columns: minmax(0, 1fr) 210px;
}
}
@media (max-height: 650px) {
.refract-studio {
gap: 8px;
padding-top: 10px;
padding-bottom: 10px;
}
.viewer {
gap: 6px;
}
}
.reference-desk {
display: flex;
flex: 1;
min-height: 0;
height: 100%;
min-width: 0;
}
.refract-studio {
flex: 1;
min-width: 0;
padding: 14px 20px 12px;
gap: 12px;
background:
radial-gradient(ellipse at 65% 15%, #7850ff0e, transparent 60%),
radial-gradient(ellipse at 0 90%, #00cae010, transparent 60%), #0f0f12;
}
.studio-heading {
min-height: 34px;
gap: 8px;
}
.heading-actions {
min-width: 0;
}
.model-selector {
border-radius: 999px;
padding: 7px 12px;
font-size: 11px;
display: block;
width: auto;
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: #1b1c21;
color: #8c99a0;
}
.studio-layout {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
flex: 1;
}
.studio-form {
flex-shrink: 0;
border: 0;
border-radius: 18px;
overflow: visible;
background: transparent;
max-height: none;
width: 100%;
}
.viewer {
flex: 1;
min-height: 130px;
display: flex;
flex-direction: column;
}
.preview-area {
position: relative;
min-height: 100px;
flex: 1;
}
.viewer-head {
font-size: 10px;
}
.desk-welcome {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 13px;
min-height: 0;
overflow: auto;
padding: 10px 25px;
text-align: center;
}
.desk-welcome h1 {
font-size: 32px;
font-weight: 650;
letter-spacing: -0.8px;
margin: 0;
color: #eff8fc;
}
.desk-emblem {
font-size: 78px;
line-height: 1.2;
color: #27c0a2;
text-shadow: 0 0 25px #27c0a233;
}
.workflow-description {
max-width: 550px;
text-align: left;
background: #1a1a1c;
border: 1px solid #00cae059;
border-radius: 11px;
padding: 14px 17px;
font-size: 12px;
color: #cdd8df;
}
.workflow-description strong {
display: block;
color: #eef6fa;
}
.workflow-description p {
font-size: 11px;
line-height: 1.8;
margin: 5px 0;
}
.workflow-description small {
font-size: 10px;
color: #71838a;
}
.tool-picker {
max-height: 65vh;
min-height: 360px;
}
.tool-picker-content {
min-height: 320px;
}
.tool-list {
min-width: 0;
}
.runtime-setting {
padding: 15px;
border: 1px solid var(--line);
border-radius: 11px;
background: #ffffff04;
margin-bottom: 15px;
display: grid;
gap: 10px;
font-size: 12px;
}
.runtime-setting a {
color: var(--accent);
font-size: 11px;
}
.param-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.param-actions > span {
margin-right: auto;
color: #7f718f;
font-size: 10px;
}
.param-actions .purple {
min-width: 150px;
}
.focused :global(.workflow-rail) {
display: none;
}
@media (max-width: 1200px) {
.refract-studio {
padding: 12px 15px;
}
.studio-layout {
display: flex;
flex-direction: column;
}
.model-selector {
max-width: 250px;
}
.tool-picker-content {
grid-template-columns: minmax(0, 1fr) 250px;
}
}
@media (max-height: 760px) {
.refract-studio {
gap: 8px;
padding-top: 10px;
}
.desk-welcome {
gap: 7px;
}
.desk-welcome h1 {
font-size: 25px;
}
.desk-emblem {
font-size: 55px;
}
.workflow-description {
padding: 10px 13px;
}
.studio-layout {
gap: 8px;
}
.viewer {
min-height: 110px;
}
}
.reference-preferences :global(.form) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.reference-preferences :global(.field) {
border: 1px solid #ffffff0d;
background: #ffffff04;
border-radius: 11px;
padding: 13px;
}
.reference-preferences :global(.field .label) {
font-size: 12px;
margin-bottom: 10px;
}
.reference-preferences :global(.field input),
.reference-preferences :global(.field select),
.reference-preferences :global(.field textarea) {
width: 100%;
font-size: 12px;
}
</style>
@@ -1,4 +1,8 @@
<script lang="ts">
import Modal from '$lib/components/ui/Modal.svelte';
import { tick } from 'svelte';
import { assetUrl } from '$lib/api';
import ParameterPopover from './ParameterPopover.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
@@ -68,6 +72,21 @@
onFromResults,
}: Props = $props();
let mediaOpen = $state(false);
let popoverInputs = $state<ComfyDynamicInput[]>([]);
let popoverAnchor = $state<HTMLElement | null>(null);
export function openMedia() {
mediaOpen = true;
}
function openParameter(inp: ComfyDynamicInput, anchor: HTMLElement) {
popoverInputs =
inp === ds.width || inp === ds.height
? [ds.width, ds.height].filter((i): i is ComfyDynamicInput => !!i)
: inp === ds.ratio
? [ds.ratio, ds.megapixels].filter((i): i is ComfyDynamicInput => !!i)
: [inp];
popoverAnchor = anchor;
}
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
let root = $state<HTMLElement | null>(null);
let dragDepth = $state(0);
@@ -101,11 +120,17 @@
}
}
function onDrop(e: DragEvent) {
async function onDrop(e: DragEvent) {
e.preventDefault();
dragDepth = 0;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) tray?.dropFiles(files);
if (files.length) {
if (dock) {
mediaOpen = true;
await tick();
}
tray?.dropFiles(files);
}
}
function hasFiles(e: DragEvent): boolean {
@@ -134,7 +159,7 @@
{#if current}
<section class="input-section prompt-section">
<h3>2 · 准备这次创作</h3>
{#if hasSlots}<div class="slots">
{#if hasSlots && !dock}<div class="slots">
<SlotTray
bind:this={tray}
bind:busy={slotBusy}
@@ -146,6 +171,29 @@
{onFromResults}
/>
</div>{/if}
{#if dock}<div class="media-summary">
{#if hasSlots}
{#each ds.slots.slice(0, 4) as slot}<button
class="media-token"
onclick={openMedia}
aria-label={'设置' + slot.label}
>
{#if values[slot.inp.nodeId] && slot.mediaKind === 'image'}<img
src={assetUrl(String(values[slot.inp.nodeId]))}
alt=""
/>{:else}<Icon name="plus" size={12} />{/if}
{slot.label}{slot.inp.required ? ' *' : ''}
</button>{/each}
{#if ds.lists.length}<button
class="media-token"
onclick={openMedia}>+ 参考材料</button
>{/if}
<button class="media-token" onclick={openMedia}
>{slotBusy ? '正在上传…' : '管理素材'}</button
>
{:else}<small>描述你的画面</small>{/if}
<small class="input-shortcut">Ctrl + Enter 生成</small>
</div>{/if}
<div class="prompt-pair" class:dual={dock && !!ds.negative}>
{#if ds.prompt}<div class="prompt-field">
<label for="desk-prompt"
@@ -256,11 +304,17 @@
{#if dock}<div class="dock-chips">
<button id="desk-conn" class="parameter" onclick={onOpenParams}
>✦ {endpointName}⌄</button
>{#each commonInputs.slice(0, 3) as inp}<button
>{#each commonInputs
.filter((i) => i !== ds.height)
.slice(0, 3) as inp}<button
class="parameter"
onclick={onOpenParams}
onclick={(e) => openParameter(inp, e.currentTarget)}
>{inp.label}: {String(
values[inp.nodeId] ?? inp.defaultValue ?? '—',
inp === ds.width && ds.height
? String(values[inp.nodeId]) +
' × ' +
String(values[ds.height.nodeId])
: (values[inp.nodeId] ?? inp.defaultValue ?? '—'),
)}⌄</button
>{/each}<button class="parameter" onclick={onOpenParams}
>创作设置⌄</button
@@ -291,6 +345,46 @@
</div>
</div>
{#if dock}
<Modal
bind:open={mediaOpen}
size="lg"
title="本次创作素材"
dismissible={!slotBusy}
>
<div class="media-editor">
<SlotTray
bind:this={tray}
bind:busy={slotBusy}
{ds}
{values}
{showErrors}
{errors}
{onSet}
{onFromResults}
/>
</div>
{#snippet footer()}<button
class="a-btn secondary"
disabled={slotBusy}
onclick={() => (mediaOpen = false)}
>{slotBusy ? '正在上传…' : '完成'}</button
>{/snippet}
</Modal>
{/if}
<ParameterPopover
inputs={popoverInputs}
{values}
anchor={popoverAnchor}
onClose={() => {
popoverInputs = [];
popoverAnchor = null;
}}
onApply={(v) => {
for (const [id, value] of Object.entries(v)) onSet(id, value);
}}
/>
<style>
.composer-wrap,
.composer {
@@ -425,171 +519,204 @@
color: var(--err);
}
/* Dock composition keeps submission outside the scrollable media and prompts. */
.dock {
height: auto;
height: 100%;
min-height: 0;
}
.dock .composer {
background: rgba(19, 18, 25, 0.82);
backdrop-filter: blur(18px) saturate(1.4);
border: 1px solid #ffffff26;
border-radius: 18px;
box-shadow: 0 12px 40px #0009;
height: 100%;
min-height: 0;
border: 1px solid #f5b04246;
border-radius: 16px;
background: linear-gradient(125deg, #1b1a1d, #17161a);
overflow: hidden;
box-shadow: inset 0 0 0 1px #f5b04206;
}
.dock .composer:focus-within {
border-color: #f5b04273;
box-shadow:
0 12px 40px #0009,
0 0 0 1px #f5b04259,
0 8px 28px #f5b0422e;
border-color: #f5b04280;
}
.dock .composer-scroll {
padding: 16px 18px 9px;
max-height: min(280px, 36vh);
overflow: auto;
padding: 9px 14px 4px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.dock .input-section {
margin: 0;
padding: 0;
border: 0;
margin: 0;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.dock .input-section > h3 {
display: none;
}
.media-summary {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
margin-bottom: 6px;
flex-shrink: 0;
overflow: auto;
}
.media-summary > small {
color: var(--text-3);
font-size: 10px;
white-space: nowrap;
}
.media-token {
display: flex;
align-items: center;
gap: 5px;
background: #ffffff04;
border: 1px dashed #ffffff25;
border-radius: 6px;
padding: 3px 7px;
font-size: 10px;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
height: 26px;
}
.media-token img {
height: 20px;
width: 20px;
object-fit: cover;
border-radius: 3px;
}
.input-shortcut {
margin-left: auto;
}
.dock .prompt-pair {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
min-height: 0;
flex: 1;
gap: 18px;
}
.dock .prompt-pair.dual {
grid-template-columns: 1fr 1fr;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.65fr);
}
.dock .prompt-field {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.dock .negative {
border-left: 1px solid var(--line);
padding-left: 20px;
}
.dock .prompt-field {
min-width: 0;
padding-left: 18px;
}
.dock .prompt-field label {
font-size: 11px;
color: #87959e;
margin: 0 0 8px;
font-size: 10px;
color: var(--text-3);
margin: 0 0 4px;
display: flex;
gap: 6px;
}
.dock .prompt-field:focus-within label {
color: #f5b042;
.dock .prompt-field:first-child label {
color: #d0b37d;
}
.dock .prompt {
border: 0 !important;
background: none !important;
box-shadow: none !important;
background: none;
border: 0;
border-radius: 0;
padding: 0;
min-height: 65px;
height: 75px;
resize: vertical;
font-size: 13.4px;
line-height: 24.1px;
color: #fffaf2ed;
font-size: 12px;
line-height: 1.6;
resize: none;
min-height: 0;
max-height: none;
height: 100%;
flex: 1;
overflow: auto;
}
.dock .prompt:focus {
outline: 0 !important;
outline: 0;
}
.prompt-counter {
display: flex;
justify-content: space-between;
font-size: 9px;
color: #657078;
margin-top: 5px;
color: var(--text-3);
margin-top: 3px;
}
.dock .slots {
margin-bottom: 12px;
max-height: 155px;
overflow: auto;
}
.dock .slots :global(.slots-grid) {
gap: 8px;
.dock .prompt-counter {
display: none;
}
.dock .submit {
display: flex;
flex-wrap: nowrap;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 16px 13px;
padding: 7px 14px;
height: 45px;
box-sizing: border-box;
background: none;
border-top: 1px solid var(--line);
position: relative;
background: transparent;
border-top: 1px solid #ffffff0a;
}
.dock-chips {
display: flex;
gap: 5px;
align-items: center;
gap: 6px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
overflow: auto;
}
.parameter {
border: 1px solid #ffffff17;
background: #19171f;
border-radius: 8px;
padding: 7px 9px;
color: #c3bfcb;
background: #25242a;
border: 1px solid #ffffff07;
border-radius: 7px;
padding: 5px 8px;
font-size: 10px;
color: #c4c2cb;
cursor: pointer;
max-width: 200px;
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
transition:
background-color 180ms ease,
border-color 180ms ease;
flex-shrink: 0;
}
.parameter:hover {
border-color: #f5b04255;
}
.dock .gen {
width: auto;
border-radius: 999px;
background: #f5b042;
color: #251b0a;
border-color: #f5b042;
box-shadow: none;
padding: 9px 18px;
font-size: 12px;
min-height: 32px;
font-size: 11px;
padding: 7px 18px;
border-radius: 20px;
margin-left: auto;
flex-shrink: 0;
}
.dock .why,
.dock .ready {
font-size: 10px;
order: 3;
flex-basis: 100%;
margin: 0;
text-align: left;
min-height: 0;
padding: 0;
background: none;
border: 0;
background: linear-gradient(140deg, #ffc65e, #f5b042);
color: #30210c;
border-color: #edb458;
box-shadow: 0 3px 12px #f5b04217;
}
.dock .why {
position: absolute;
bottom: calc(100% + 3px);
right: 14px;
max-width: 65%;
padding: 3px 8px;
border-radius: 5px;
background: #241e17;
font-size: 10px;
color: var(--warn);
cursor: pointer;
z-index: 1;
}
.dock .why.err {
color: var(--err);
background: #241717;
}
.dock .ready {
display: none;
}
.dock .gen:hover {
background: #ffc96a;
}
.parameter:hover {
border-color: #f5b04255;
background: #f5b0420d;
}
.dock .gen:active {
transform: scale(var(--press-scale));
.dock .error {
font-size: 10px;
}
</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>
@@ -176,7 +176,13 @@
href: '/library',
label: '素材库',
icon: 'assets',
match: (p: string) => ['/library', '/workflows'].includes(p),
match: (p: string) => p === '/library',
},
{
href: '/workflows',
label: '工作流',
icon: 'folder',
match: (p: string) => p === '/workflows',
},
{
href: '/agents',
@@ -313,7 +319,7 @@
position: fixed;
inset: 0;
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
grid-template-columns: 68px minmax(0, 1fr);
background: var(--bg);
color: var(--text);
min-width: 0;
@@ -339,7 +345,7 @@
text-decoration: none;
letter-spacing: -1px;
display: block;
margin: 6px 0 28px;
margin: 2px 0 18px;
text-shadow: 0 0 20px #00cae044;
}
.brand small {
@@ -356,12 +362,12 @@
gap: 7px;
}
.nav-item {
min-height: 58px;
min-height: 50px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 7px;
gap: 5px;
font-size: 10px;
padding: 9px 3px;
color: #90949b;
@@ -444,7 +450,7 @@
min-width: 0;
}
.topbar {
height: 54px;
height: 52px;
flex-shrink: 0;
background: #141419;
border-bottom: 1px solid var(--line);
@@ -554,4 +560,27 @@
min-height: 37px;
}
}
@media (max-height: 740px) {
.sidebar {
padding-top: 10px;
}
.brand {
margin-bottom: 12px;
}
.sidebar nav {
gap: 4px;
}
.nav-item {
min-height: 42px;
padding: 7px 2px;
}
.sidebar-bottom {
padding-top: 8px;
}
.sidebar-bottom .nav-item {
min-height: 31px;
padding: 4px 2px;
}
}
</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 {
@@ -29,6 +30,7 @@
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
let panel = $state<HTMLDivElement | null>(null);
const layer = Symbol('dialog');
function requestClose() {
if (!dismissible) return;
@@ -36,6 +38,7 @@
}
function handleKeydown(event: KeyboardEvent) {
if (!topModal(layer)) return;
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
@@ -75,20 +78,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?.();
};
});
@@ -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;
}
+132 -2
View File
@@ -19,7 +19,6 @@
type Job,
type Project,
} from '$lib/api';
import Hero from '$lib/components/refract/Hero.svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
import AProjectCard from '$lib/components/home/AProjectCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
@@ -390,7 +389,31 @@
<h2>欢迎回到工作室。</h2>
<span>给今天的想象,留一点空间。</span>
</div>
<Hero onStart={openIdea} onImport={openScript} />
<section class="studio-start">
<div>
<small>从这里开始今天的创作</small>
<h1>让想法,成为下一件作品。</h1>
<p>直接生成图片与视频,或与智能体一起推进项目。</p>
<div class="start-actions">
<button class="a-btn gold" onclick={openIdea}>+ 新建项目</button
><button class="a-btn secondary" onclick={openScript}
>导入剧本</button
>
</div>
</div>
<img src="/refract/coast.jpg" alt="海岸光线与画面参考" />
</section>
{#if allProjects.length}{@const recent = allProjects[0]}<a
class="resume-project"
href={'/workbench/' + recent.id}
><Icon name="folder" size={22} />
<div>
<small>继续最近的项目</small><strong>{recent.title}</strong>
</div>
<span>{recent.stats?.stage_confirmed ?? 0} / 5 阶段已确认</span><b
>继续创作 →</b
></a
>{/if}
<section class="refract-tools" aria-label="创作工具">
{#each [{ href: '/image', icon: 'image', title: '图片创作', desc: '从文字、参考图到新画面' }, { href: '/video', icon: 'film', title: '视频创作', desc: '首尾帧、参考视频与运镜' }, { href: '/canvas', icon: 'story', title: '灵感画布', desc: '把零散的想法连接起来' }, { href: '/agents', icon: 'sparkle', title: '智能体', desc: '让专业搭档加入创作' }] as tool}
<a href={tool.href}
@@ -1071,4 +1094,111 @@
background: #ffffff06;
}
}
.home {
max-width: none;
padding: 24px 28px 30px;
grid-template-columns: minmax(0, 1fr) 260px;
gap: 24px;
}
.studio-start {
display: flex;
justify-content: space-between;
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
background: #16161b;
min-height: 210px;
}
.studio-start > div {
padding: 28px;
display: flex;
flex-direction: column;
justify-content: center;
}
.studio-start small {
font-size: 10px;
color: var(--accent);
}
.studio-start h1 {
font-size: 27px;
font-weight: 550;
margin: 12px 0;
letter-spacing: -0.8px;
}
.studio-start p {
font-size: 12px;
color: var(--text-3);
margin: 0 0 18px;
}
.studio-start img {
width: 38%;
object-fit: cover;
}
.start-actions {
display: flex;
gap: 10px;
}
.resume-project {
display: flex;
align-items: center;
gap: 14px;
padding: 15px 18px;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--text);
text-decoration: none;
background: #16161b;
}
.resume-project small {
display: block;
font-size: 10px;
color: var(--text-3);
}
.resume-project strong {
display: block;
font-size: 14px;
font-weight: 550;
margin-top: 5px;
}
.resume-project > span {
margin-left: auto;
color: var(--text-3);
font-size: 11px;
}
.resume-project b {
font-size: 11px;
font-weight: 500;
color: var(--accent);
}
.refract-tools {
gap: 10px;
}
.refract-tools a {
padding: 12px;
}
.cards {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.tasks {
padding: 16px;
background: #16161b;
border-radius: 12px;
}
@media (max-width: 1200px) {
.home {
grid-template-columns: minmax(0, 1fr);
padding: 20px;
}
.refract-tools {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.studio-start h1 {
font-size: 24px;
}
.tasks {
position: static;
}
}
</style>
+531 -209
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
@@ -32,6 +32,7 @@
type NamedAgent,
type SkillAdminEntry,
type SkillVersion,
type WorkbenchStageKey,
canvasApi,
workflows,
workbenchApi,
@@ -172,14 +173,14 @@
function selectAgent(key: string) {
if (sendPending || uploadPending || projectPending) return;
agentDrafts = { ...agentDrafts, [activeKey]: draft };
draft = agentDrafts[key] ?? '';
localStorage.setItem('calliope.agents.activeKey', key);
activeKey = key;
resetStreaming();
}
function selectSession(id: number) {
if (sendPending || uploadPending || projectPending) return;
activeId = id;
rememberSession(activeKey, id);
resetStreaming();
@@ -193,9 +194,7 @@
agent_key: activeKey,
project_id: currentPreference.project,
});
draft = '';
agentDrafts = { ...agentDrafts, [activeKey]: '' };
agentAttachments = { ...agentAttachments, [activeKey]: [] };
rememberSession(activeKey, s.id);
await client.invalidateQueries({ queryKey: ['agents-page-sessions'] });
activeId = s.id;
@@ -775,8 +774,41 @@
.reverse()
.find((m) => m.role === 'assistant' && m.content?.trim()),
);
let agentFile: HTMLInputElement;
let agentPrompt: HTMLTextAreaElement;
let workingText = $state('');
let workingIdentity = $state('');
let workingDrafts = $state<Record<string, string>>({});
try {
workingDrafts = JSON.parse(
sessionStorage.getItem('calliope.agent.workingDrafts') ?? '{}',
);
} catch {}
$effect(() => {
const id = latestAssistant
? String(activeId) + ':' + latestAssistant.id
: '';
const content = latestAssistant?.content ?? '';
untrack(() => {
if (id === workingIdentity) return;
if (workingIdentity)
workingDrafts = { ...workingDrafts, [workingIdentity]: workingText };
workingIdentity = id;
workingText = workingDrafts[id] ?? content;
});
});
function updateWorking(text: string) {
workingText = text;
if (workingIdentity)
workingDrafts = { ...workingDrafts, [workingIdentity]: text };
try {
sessionStorage.setItem(
'calliope.agent.workingDrafts',
JSON.stringify(workingDrafts),
);
} catch {}
}
let agentFile = $state<HTMLInputElement>(null!);
let agentPrompt = $state<HTMLTextAreaElement>(null!);
let agentPreferences = $state<
Record<
string,
@@ -790,6 +822,55 @@
>({});
let agentDrafts = $state<Record<string, string>>({});
let agentAttachments = $state<Record<string, AgentAttachment[]>>({});
let compositionIdentity = $state('');
const compositionCache = new Map<
string,
{ text: string; files: AgentAttachment[] }
>();
try {
for (const [key, value] of Object.entries(
JSON.parse(sessionStorage.getItem('calliope.agent.compositions') ?? '{}'),
))
compositionCache.set(
key,
value as { text: string; files: AgentAttachment[] },
);
} catch {}
$effect(() => {
const key = activeKey,
sid = activeId;
const identity = key + ':' + (sid ?? 'new');
untrack(() => {
if (compositionIdentity === identity) return;
if (compositionIdentity) {
const previousKey = compositionIdentity.split(':')[0];
compositionCache.set(compositionIdentity, {
text: draft,
files: $state.snapshot(agentAttachments[previousKey] ?? []),
});
}
compositionIdentity = identity;
const cached = compositionCache.get(identity);
draft = cached?.text ?? '';
agentAttachments = { ...agentAttachments, [key]: cached?.files ?? [] };
});
});
$effect(() => {
const text = draft,
files = attachments,
identity = compositionIdentity;
if (!identity) return;
untrack(() => {
compositionCache.set(identity, { text, files: $state.snapshot(files) });
try {
sessionStorage.setItem(
'calliope.agent.compositions',
JSON.stringify(Object.fromEntries(compositionCache)),
);
} catch {}
});
});
const uploadManager = createUploadManager();
const theme = $derived(agentThemes[activeKey] ?? agentThemes.script);
const agentChoices = $derived(
@@ -985,7 +1066,7 @@
}
function editResult() {
if (!latestAssistant) return;
resultText = latestAssistant.content;
resultText = workingText || latestAssistant.content;
resultKey = activeKey;
resultProject = sessionProject;
resultError = '';
@@ -1010,19 +1091,16 @@
resultOpen = false;
await goto('/workbench/' + resultProject + '?stage=script');
} else {
try {
await navigator.clipboard.writeText(resultText);
} catch {
const area = document.getElementById(
'agent-result-editor',
) as HTMLTextAreaElement;
area.focus();
area.select();
if (!document.execCommand('copy'))
throw new Error('复制失败,请在文本框内手动复制后打开项目。');
}
await workbenchApi.saveNote(
resultProject,
resultKey as WorkbenchStageKey,
resultText,
);
await client.invalidateQueries({
queryKey: ['workbench', resultProject],
});
resultOpen = false;
toast.info('已复制创作内容,请在项目对应字段中粘贴。');
toast.success('已保存到项目的助手建议区,结构化内容仍由你整理并确认。');
await goto('/workbench/' + resultProject + '?stage=' + resultKey);
}
} catch (err) {
@@ -1094,7 +1172,6 @@
<div class="reference-agents">
<aside class="agent-rail" aria-label="智能体列表">
<header>
<i>✧</i>
<div>
<strong>智能体工坊</strong><small>让创作助手,陪你完成想法</small>
</div>
@@ -1158,8 +1235,10 @@
/>
</div>
<header class="agent-toolbar">
<button class="a-btn gold sm" onclick={() => (sessionsOpen = true)}
>◷ 对话记录</button
<button
class="a-btn gold sm"
disabled={sendPending || uploadPending || projectPending}
onclick={() => (sessionsOpen = true)}>◷ 对话记录</button
>
<div class="grow"></div>
<button
@@ -1173,7 +1252,10 @@
onclick={exportMarkdown}>导出对话</button
><button
class="a-btn ghost sm"
disabled={running || sendPending}
disabled={running ||
sendPending ||
uploadPending ||
projectPending}
onclick={() => (clearOpen = true)}>清空</button
>{/if}
</header>
@@ -1200,45 +1282,71 @@
<p>
{activeAgent.tagline} · 选择技能与工具,与智能体一起打磨创作。
</p>
</div>{/if}
{#if messages.length || running}<section class="agent-conversation">
<header>
<div>
<span>AI {activeAgent.name}</span>
<h2>{$sessionQuery.data?.title ?? '这次创作'}</h2>
</div>
<span class="session-status"
>{running ? '● 正在创作' : '等待你的下一步'}</span
>{#if sessionProject}<a
class="a-btn secondary sm"
href={'/workbench/' + sessionProject}>回到项目 ↗</a
>{/if}
</header>
{#if $sessionQuery.data?.plan}<AgentPlanPanel
plan={$sessionQuery.data.plan}
/>{/if}{#if latestAssistant && !running}<div
class="agent-result-actions"
<div class="inspiration-options">
<button onclick={useInspiration}>从一个故事灵感开始 →</button
><button onclick={openSkills}>选择专业技能 →</button><button
onclick={openStyles}>找一个视觉方向 →</button
>
<button class="a-btn secondary sm" onclick={editResult}
>编辑内容并带回项目 ↗</button
>
</div>{/if}
<div class="chat-body">
<AgentChat
{messages}
{liveTools}
{streaming}
{streamingReasoning}
running={running === true}
{suggestions}
loading={activeId != null &&
$sessionQuery.isFetching &&
!messages.length}
onSuggestion={(p) => (draft = p)}
onAnswer={handleAnswer}
/>
</div>
</section>{/if}
</div>{/if}
{#if messages.length || running}<div class="agent-dialogue">
<section class="agent-conversation">
<header>
<div>
<span>AI {activeAgent.name}</span>
<h2>{$sessionQuery.data?.title ?? '这次创作'}</h2>
</div>
<span class="session-status"
>{running ? '● 正在创作' : '等待你的下一步'}</span
>{#if sessionProject}<a
class="a-btn secondary sm"
href={'/workbench/' + sessionProject}>回到项目 ↗</a
>{/if}
</header>
{#if $sessionQuery.data?.plan}<AgentPlanPanel
plan={$sessionQuery.data.plan}
/>{/if}
<div class="chat-body">
<AgentChat
{messages}
{liveTools}
{streaming}
{streamingReasoning}
running={running === true}
{suggestions}
loading={activeId != null &&
$sessionQuery.isFetching &&
!messages.length}
onSuggestion={(p) => (draft = p)}
onAnswer={handleAnswer}
/>
</div>
</section>
<aside class="agent-artifact" aria-label="可编辑创作成果">
<header>
<span>创作成果</span><small
>{latestAssistant ? '可编辑草稿' : '等待助手回复'}</small
>
</header>
{#if latestAssistant}
<textarea
id="agent-working-draft"
aria-label="编辑创作成果"
value={workingText}
oninput={(e) => updateWorking(e.currentTarget.value)}
disabled={running}
></textarea>
<footer>
<small>修改保存在当前会话中,带回项目后仍需确认。</small
><button
class="a-btn purple sm"
disabled={running}
onclick={editResult}>带回项目 ↗</button
>
</footer>
{:else}<p>回复完成后,可以在这里编辑并带回项目。</p>{/if}
</aside>
</div>{/if}
<section class="agent-composer" aria-label="智能体任务输入">
<textarea
bind:this={agentPrompt}
@@ -1251,13 +1359,18 @@
agentDrafts = { ...agentDrafts, [activeKey]: draft };
}}
onkeydown={onKeydown}
disabled={running || sendPending}
disabled={running ||
sendPending ||
uploadPending ||
projectPending}
></textarea>
<div class="agent-input-info">
<button
onclick={useInspiration}
disabled={running || sendPending}
>♧ 没有头绪?看看创作灵感</button
disabled={running ||
sendPending ||
uploadPending ||
projectPending}>♧ 没有头绪?看看创作灵感</button
><small>{draft.length} / 20000</small>
</div>
{#if attachments.length}<div class="agent-attachments">
@@ -1266,7 +1379,10 @@
src={assetUrl(a.path)}
alt={a.name}
/>{/if}<span>{a.name}</span><button
disabled={running || sendPending}
disabled={running ||
sendPending ||
uploadPending ||
projectPending}
aria-label={'移除' + a.name}
onclick={() => removeAttachment(a.path)}>×</button
>
@@ -1337,34 +1453,6 @@
onchange={addAgentFiles}
/>
</section>
{#if !messages.length && !running}<section
class="agent-welcome-card"
>
<img
src={'/reference-agent/' + chosenStyle.image}
alt={chosenStyle.name + '风格参考'}
/>
<div>
<span>✦ 从想法到作品</span>
<h2>一位创作搭档,陪你把故事讲好。</h2>
<p>
选择技能与模型,写下任务。智能体会展示真实的回复、技能使用与创作内容,需要确认时在对话中继续。
</p>
<div class="agent-frame-strip">
{#each [8, 1, 2, 7, 0] as i}<img
src={'/reference-agent/' + agentStyles[i].image}
alt={agentStyles[i].name}
/>{/each}
</div>
<button class="a-btn ghost sm" onclick={useInspiration}
>从一个灵感开始 ↗</button
>
</div>
</section>
<div class="agent-project-links">
<a href="/workbench">▢ 我的项目 <span>继续五阶段创作 →</span></a
><a href="/image">✦ 创作台 <span>生成图片与视频 →</span></a>
</div>{/if}
{/if}
</div>
</section>
@@ -1489,6 +1577,7 @@
>
<Modal bind:open={sessionsOpen} size="lg" title="对话记录"
>{#each activeSessions as session (session.id)}<button
disabled={sendPending || uploadPending || projectPending}
class="model-choice"
onclick={() => {
selectSession(session.id);
@@ -1768,7 +1857,7 @@
? '正在保存…'
: resultKey === 'script'
? '保存到项目草稿'
: '复制内容并打开项目'}</button
: '保存建议并打开项目'}</button
>
</div>{/snippet}<label class="result-project-label"
>带回哪个项目<select
@@ -1790,7 +1879,7 @@
<p class="empty-skills">
{resultKey === 'script'
? '保存为故事构思阶段的待确认草稿,不会直接确认或覆盖已确认版本。'
: '这个阶段有多个结构化字段,内容会复制到剪贴板,再到项目中粘贴到需要的字段。'}
: '内容会保存到这个阶段的助手建议区;镜头、风格和素材等字段仍由你整理并确认。'}
</p>
{#if resultError}<p class="result-error" role="alert">
{resultError}
@@ -2336,19 +2425,6 @@
background: radial-gradient(at 10% 50%, #7850ff24, transparent 70%);
flex-shrink: 0;
}
.agent-rail header i {
font-style: normal;
font-size: 22px;
color: #b99aff;
background: #7850ff18;
border: 1px solid #7850ff80;
width: 39px;
height: 39px;
border-radius: 50%;
display: grid;
place-items: center;
box-shadow: 0 0 22px #7850ff24;
}
.agent-rail header strong {
font-size: 17px;
color: #b99aff;
@@ -2743,88 +2819,6 @@
.agent-option:active {
transform: scale(var(--press-scale));
}
.agent-welcome-card {
margin-top: 26px;
border: 1px solid #ffffff10;
background: linear-gradient(135deg, #111015, #211c2eee);
border-radius: 18px;
display: flex;
gap: 22px;
padding: 19px;
align-items: center;
}
.agent-welcome-card > img {
width: 164px;
height: 170px;
object-fit: cover;
border-radius: 12px;
flex-shrink: 0;
}
.agent-welcome-card > div {
min-width: 0;
flex: 1;
}
.agent-welcome-card span {
font-size: 10px;
display: inline-flex;
color: #bda5ff;
border-radius: 999px;
padding: 2px 9px;
background: #7850ff1f;
}
.agent-welcome-card h2 {
font-size: 20px;
line-height: 1.6;
margin: 8px 0;
color: #fffaf2ed;
font-weight: 650;
}
.agent-welcome-card p {
font-size: 11px;
color: #a29ba9;
line-height: 1.9;
margin: 0 0 12px;
}
.agent-frame-strip {
display: flex;
gap: 7px;
margin: 12px 0;
}
.agent-frame-strip img {
width: 48px;
height: 58px;
object-fit: cover;
border-radius: 6px;
opacity: 0.88;
}
.agent-welcome-card button {
color: #f5b042;
border: 1px solid #f5b0424d;
background: #f5b04208;
}
.agent-project-links {
display: flex;
gap: 12px;
margin-top: 25px;
}
.agent-project-links a {
flex: 1;
text-decoration: none;
padding: 17px;
border: 1px solid #ffffff0d;
background: #121016;
border-radius: 13px;
color: #d5c9dd;
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.agent-project-links span {
font-size: 10px;
color: #776b86;
}
.agent-conversation {
border: 1px solid #ffffff12;
background: #13101aeb;
@@ -3129,12 +3123,6 @@
.style-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.agent-welcome-card h2 {
font-size: 17px;
}
.agent-project-links span {
display: none;
}
}
@media (max-height: 760px) {
.agent-intro {
@@ -3150,10 +3138,6 @@
.agent-composer textarea {
min-height: 84px;
}
.agent-welcome-card > img {
height: 142px;
width: 125px;
}
.agent-toolbar {
padding-top: 10px;
padding-bottom: 10px;
@@ -3167,12 +3151,6 @@
:global(:root[data-reduce='1']) .agent-entry:hover .avatar {
transform: none;
}
.agent-result-actions {
padding: 10px 20px 0;
display: flex;
justify-content: flex-end;
}
.agent-result-editor {
width: 100%;
min-height: 260px;
@@ -3196,10 +3174,10 @@
}
.agent-intro {
animation: agent-arrive 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation: agent-arrive var(--d-enter) var(--ease-out) both;
}
.agent-content:not(.conversation) .agent-composer {
animation: agent-arrive 700ms cubic-bezier(0.22, 1, 0.36, 1) 220ms both;
animation: agent-arrive var(--d-enter) var(--ease-out) both;
}
@keyframes agent-arrive {
from {
@@ -3211,4 +3189,348 @@
transform: translateY(0);
}
}
.agent-rail {
width: 232px;
background: #121216;
}
.agent-rail > header {
height: auto;
padding: 21px 18px 16px;
background: none;
}
.agent-rail header strong {
color: var(--text);
font-size: 18px;
}
.agent-rail header small {
font-size: 11px;
color: var(--text-3);
}
.agent-entry {
margin: 2px 0;
}
.agent-entry.selected {
background: linear-gradient(120deg, #a98bff16, #a98bff03);
border-color: #a98bff39;
}
.agent-row {
min-height: 55px;
padding: 10px 8px;
}
.agent-row .avatar {
width: 32px;
height: 32px;
font-size: 14px;
}
.agent-stage {
position: relative;
display: grid;
grid-template-rows: 44px minmax(0, 1fr);
padding: 0 22px 14px;
min-height: 0;
overflow: hidden;
flex: 1;
}
.agent-toolbar {
padding: 0;
position: relative;
z-index: 1;
}
.agent-toolbar :global(.a-btn) {
min-height: 28px;
font-size: 11px;
padding: 4px 8px;
}
.agent-scene {
display: none;
}
.agent-content,
.agent-content.conversation {
display: grid;
grid-template-rows: minmax(0, 1fr) 173px;
padding: 0;
gap: 12px;
overflow: hidden;
height: 100%;
min-height: 0;
max-width: none;
position: relative;
}
.agent-intro {
grid-area: 1/1;
padding: 32px 28px 22px;
margin: 0;
min-height: 0;
isolation: isolate;
text-align: left;
overflow: auto;
border-radius: 14px;
}
.agent-intro::before {
content: '';
position: absolute;
inset: 0 0 0 30%;
z-index: -2;
background: url('/reference-agent/story-hero.webp') right top/cover
no-repeat;
opacity: 0.7;
}
.agent-intro::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background:
linear-gradient(90deg, #0f0f12 12%, #0f0f12dd 32%, #0f0f1230 80%),
linear-gradient(0deg, #0f0f12, transparent 40%);
}
.agent-intro h1 {
font-size: clamp(25px, 2.6vw, 40px);
line-height: 1.4;
letter-spacing: -1px;
max-width: 560px;
margin: 14px 0;
}
.agent-intro p {
color: #b4adb9;
font-size: 12px;
max-width: 390px;
line-height: 1.8;
}
.agent-composer {
grid-area: 2/1;
height: 100%;
min-height: 0;
margin: 0;
padding: 10px 14px 6px;
max-width: none;
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
border-color: #a98bff3d;
background: linear-gradient(120deg, #1b1823, #19171d);
}
.agent-composer textarea {
flex: 1;
min-height: 0;
height: auto;
max-height: none;
resize: none;
font-size: 13px;
padding: 2px;
overflow: auto;
}
.agent-input-info {
margin: 2px 0 4px;
padding: 0;
font-size: 10px;
flex-shrink: 0;
}
.agent-input-info button {
font-size: 10px;
}
.agent-controls {
display: flex;
flex-wrap: nowrap;
gap: 5px;
align-items: center;
border-top: 1px solid var(--line);
padding-top: 6px;
flex-shrink: 0;
margin-top: 0;
}
.agent-option,
.project-option {
font-size: 10px;
padding: 5px 7px;
min-height: 28px;
max-width: 140px;
white-space: nowrap;
}
.agent-option img {
width: 17px;
height: 17px;
}
.project-option select {
font-size: 10px;
max-width: 120px;
}
.agent-submit-row {
display: flex;
flex: 1;
gap: 5px;
justify-content: flex-end;
min-width: 0;
width: auto;
margin: 0;
padding: 0;
border: 0;
}
.agent-submit-row small {
display: none;
}
.agent-send {
min-height: 32px;
padding: 6px 16px;
font-size: 11px;
flex-shrink: 0;
}
.agent-attachments {
display: flex;
gap: 5px;
max-height: 30px;
overflow: auto;
flex-shrink: 0;
margin: 0 0 4px;
}
.agent-attachments img {
width: 22px;
height: 22px;
}
.agent-dialogue {
display: grid;
grid-template-columns: minmax(0, 1fr) 310px;
gap: 14px;
min-height: 0;
overflow: hidden;
}
.agent-conversation {
width: 100%;
max-width: none;
padding: 0;
margin: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.agent-conversation > header {
height: 52px;
flex-shrink: 0;
padding: 8px 12px;
}
.chat-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.agent-artifact {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid #a98bff25;
border-radius: 12px;
background: #18171e;
overflow: hidden;
}
.agent-artifact header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
font-size: 12px;
}
.agent-artifact header small,
.agent-artifact footer small,
.agent-artifact p {
color: var(--text-3);
font-size: 10px;
}
.agent-artifact textarea {
flex: 1;
min-height: 0;
border: 0;
background: none;
padding: 16px;
color: var(--text-2);
font-size: 12px;
line-height: 1.8;
resize: none;
outline: 0;
}
.agent-artifact textarea:focus {
background: #a98bff04;
}
.agent-artifact footer {
padding: 10px 12px;
border-top: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-artifact p {
padding: 20px;
}
@media (max-width: 1200px) {
.agent-rail {
width: 210px;
}
.agent-dialogue {
grid-template-columns: minmax(0, 1fr) 260px;
}
.agent-stage {
padding: 0 14px 12px;
}
.agent-controls {
flex-wrap: wrap;
}
.agent-option,
.project-option {
max-width: 135px;
}
}
@media (max-height: 740px) {
.agent-content,
.agent-content.conversation {
grid-template-rows: minmax(0, 1fr) 160px;
}
.agent-intro {
padding: 22px;
}
}
.inspiration-options {
display: flex;
gap: 10px;
margin-top: 28px;
flex-wrap: wrap;
max-width: 600px;
}
.inspiration-options button {
background: #16141bc9;
border: 1px solid #ffffff15;
border-radius: 9px;
padding: 12px 14px;
color: var(--text-2);
font-size: 11px;
cursor: pointer;
}
.agent-content,
.agent-content.conversation {
width: 100%;
max-width: none;
margin: 0;
align-items: stretch;
justify-items: stretch;
}
.agent-intro {
width: auto;
max-width: none;
box-sizing: border-box;
}
.agent-stage {
inset: auto;
position: relative;
}
.reference-agents {
height: 100%;
}
.agent-composer::before {
display: none;
}
</style>
+2 -2
View File
@@ -76,8 +76,8 @@
--r-lg: 14px;
--r-pill: 999px;
--shadow-float: 0 20px 60px #0008, 0 0 0 1px var(--line);
--rail-w: 78px;
--topbar-h: 54px;
--rail-w: 68px;
--topbar-h: 52px;
--tabbar-h: 0px;
--sprocket: none;
--frame-w: 0px;
@@ -503,4 +503,16 @@
border-color: var(--accent);
color: var(--accent);
}
.library {
max-width: none;
padding: 24px 28px 30px;
}
h1 {
font-size: 26px;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
</style>
+324 -175
View File
@@ -2,6 +2,7 @@
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { untrack } from 'svelte';
import { fade } from 'svelte/transition';
import Modal from '$lib/components/ui/Modal.svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
@@ -12,7 +13,6 @@
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { classifyEndpoint, endpointTypeLabel } from '$lib/endpointKind';
import { isWideLayout } from '$lib/wideLayout';
const client = useQueryClient();
@@ -69,7 +69,7 @@
// Form state — add / edit custom LLM (spec §9.3)
// ------------------------------------------------------------------
type FormMode = 'idle' | 'add' | 'edit';
let formMode = $state<FormMode>(isWideLayout() ? 'add' : 'idle');
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);
@@ -80,7 +80,7 @@
const TIMEOUT_OPTIONS = [5, 10, 15, 30, 60];
function resetForm() {
formMode = isWideLayout() ? 'add' : 'idle';
formMode = 'idle';
editingId = null;
form = { name: '', baseUrl: '', model: '', apiKey: '', timeout: 10 };
formTest = null;
@@ -457,7 +457,11 @@
});
</script>
<AppShell active="models" title={t('models.title')} subtitle={t('models.subtitle')}>
<AppShell
active="models"
title={t('models.title')}
subtitle={t('models.subtitle')}
>
{#snippet actions()}
{#if !$settingsQuery.isLoading && $settingsQuery.data}
{@const profiles = $settingsQuery.data.llm_profiles}
@@ -516,11 +520,16 @@
{@const using = agentsUsing(s, profile.id)}
<article class="card" class:failed={rec?.ok === false}>
<div class="card-row">
<span class="card-icon"><Icon name="sparkle" size={18} /></span>
<span class="card-icon"
><Icon name="sparkle" size={18} /></span
>
<span class="card-name">{profile.name}</span>
<span class="tag tag-{kind}">{endpointTypeLabel(kind)}</span>
<span class="tag tag-{kind}">{endpointTypeLabel(kind)}</span
>
{#if isActive}
<span class="tag tag-default">{t('models.defaultBadge')}</span>
<span class="tag tag-default"
>{t('models.defaultBadge')}</span
>
{/if}
<span class="card-actions">
<Button
@@ -531,7 +540,11 @@
>
{pending ? t('models.testing') : t('models.test')}
</Button>
<Button variant="ghost" size="sm" onclick={() => startEdit(profile)}>
<Button
variant="ghost"
size="sm"
onclick={() => startEdit(profile)}
>
{t('models.edit')}
</Button>
{#if isActive}
@@ -554,31 +567,47 @@
{/if}
</span>
</div>
<p class="card-meta mono">{profile.base_url} · {profile.model}</p>
<p class="card-meta mono">
{profile.base_url} · {profile.model}
</p>
<div class="card-status">
{#if pending}
<span class="dot amber breathing"></span>
<span class="status-text amber">{t('models.testing')}</span>
<span class="status-text amber"
>{t('models.testing')}</span
>
{:else if rec?.ok}
<span class="dot green"></span>
<span class="status-text green">{t('models.statusConnected')}</span>
<span class="status-text green"
>{t('models.statusConnected')}</span
>
<span class="status-sub mono">
{t('models.latency', { ms: rec.ms ?? '?' })}{#if using > 0}
{t('models.latency', {
ms: rec.ms ?? '?',
})}{#if using > 0}
· {t('models.agentsUsing', { n: using })}{/if}
</span>
{:else if rec?.ok === false}
<span class="dot red"></span>
<span class="status-text red">{t('models.statusFailed')}</span>
<span class="status-text red"
>{t('models.statusFailed')}</span
>
<span class="status-sub mono">
{t('models.lastTestAt', { time: formatTime(rec.at) })}{#if rec.fails >= 2}
· {t('models.consecutiveFails', { n: rec.fails })}{/if}
{t('models.lastTestAt', {
time: formatTime(rec.at),
})}{#if rec.fails >= 2}
· {t('models.consecutiveFails', {
n: rec.fails,
})}{/if}
</span>
{#if rec.fails >= 3}
<span class="paused">{t('models.cloudPaused')}</span>
{/if}
{:else}
<span class="dot gray"></span>
<span class="status-text gray">{t('models.statusUntested')}</span>
<span class="status-text gray"
>{t('models.statusUntested')}</span
>
{/if}
</div>
{#if rec?.ok === false && rec.reason}
@@ -606,7 +635,9 @@
<div class="notice" role="note">
<Icon name="info" size={16} />
<p>
<strong>{t('models.noticeStrong')}</strong>{t('models.noticeBody')}
<strong>{t('models.noticeStrong')}</strong>{t(
'models.noticeBody',
)}
</p>
</div>
@@ -618,18 +649,26 @@
<div class="agents-grid">
{#each AGENTS as agent (agent.role)}
<label class="agent-cell">
<span class="avatar" style:background={agent.color} class:dark={agent.dark}>
<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)}
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}>
<option
value={p.id}
class:unavailable={tests[p.id]?.ok === false}
>
{p.name} — {p.model}{tests[p.id]?.ok === false
? t('models.unavailable')
: ''}
@@ -642,26 +681,32 @@
</section>
</section>
{#if formMode !== 'idle'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="drawer-backdrop" onclick={resetForm} aria-hidden="true"></div>
{/if}
<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>
{#if formMode === 'edit' || !isWideLayout()}
<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"
@@ -670,154 +715,180 @@
>
<Icon name="close" size={16} />
</button>
{/if}
</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">
<label class="f-field">
<span class="f-label">{t('models.fName')}<em>*</em></span>
<input
class="f-input key-input"
type="password"
class:invalid={showErrors && formErrors.apiKey}
value={form.apiKey}
class="f-input"
class:invalid={showErrors && formErrors.name}
value={form.name}
disabled={formPending}
autocomplete="off"
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.apiKey = e.currentTarget.value;
form.baseUrl = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={formMode === 'edit' && editingProfile?.api_key
? t('models.keyKeepHint')
: 'sk-…'}
placeholder="http://192.168.200.18:11434/v1"
/>
{#if formMode === 'edit' && editingProfile?.api_key && keyTail(editingProfile)}
<span class="kh mono">{keyTail(editingProfile)}</span>
{#if showErrors && formErrors.baseUrl}
<span class="f-error">{formErrors.baseUrl}</span>
{/if}
</span>
{#if showErrors && formErrors.apiKey}
<span class="f-error">{formErrors.apiKey}</span>
<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}
<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 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}
<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}
</aside>
</aside></Modal
>
</div>
{/if}
</div>
@@ -1474,4 +1545,82 @@
margin-left: 0;
}
}
.page {
max-width: none;
padding: 24px 28px 30px;
}
.layout {
display: block;
}
.main-col {
width: 100%;
max-width: none;
}
.cards {
gap: 0;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.card {
padding: 16px 20px;
border: 0;
border-bottom: 1px solid var(--line);
border-radius: 0;
background: #16161b;
}
.card:last-child {
border-bottom: 0;
}
.card-meta,
.card-status {
margin-left: 46px;
}
.form-col {
position: fixed;
top: 12px;
right: 12px;
bottom: 12px;
width: 400px;
max-width: calc(100vw - 24px);
border: 1px solid var(--line-2);
border-radius: 12px;
z-index: 800;
transform: translateX(calc(100% + 20px));
visibility: hidden;
overflow: auto;
box-shadow: var(--shadow-float);
}
.form-col.open {
transform: none;
visibility: visible;
}
.drawer-backdrop {
display: block;
position: fixed;
inset: 0;
background: var(--overlay);
z-index: 799;
}
.agents-grid {
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.form-col,
.form-col.open {
position: static;
inset: auto;
transform: none;
visibility: visible;
box-shadow: none;
border: 0;
border-radius: 0;
width: 100%;
max-width: none;
padding: 0;
}
.form-head {
display: none;
}
</style>
@@ -729,4 +729,13 @@
grid-template-columns: minmax(0, 1fr);
}
}
.page {
max-width: 1280px;
padding: 24px 28px 30px;
}
.a-panel {
border-radius: 10px;
background: #16161b;
}
</style>
+1 -1
View File
@@ -36,7 +36,7 @@
queryFn: () => jobsApi.list(),
refetchInterval: 5_000,
});
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.listMeta });
const projectsQuery = createQuery({ queryKey: ['projects'], queryFn: projects.list });
$effect(() => {
@@ -116,4 +116,26 @@
padding: 50px;
text-align: center;
}
.projects-page {
max-width: none;
padding: 24px 28px 32px;
}
.page-title {
margin-bottom: 20px;
}
.page-title h1 {
font-size: 26px;
margin: 8px 0;
}
.project-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 18px;
}
.project-grid :global(.cover) {
height: 170px;
}
.new-project {
min-height: 240px;
}
</style>
@@ -91,6 +91,7 @@
const currentStage = $derived(stages.find((s) => s.stage_key === stageKey));
function selectStage(key: string) {
noteOpen = false;
const url = new URL(pageStore.url);
url.searchParams.set('stage', key);
goto(url.toString(), { keepFocus: true, noScroll: true });
@@ -123,6 +124,23 @@
let runWatchKey = $state<WorkbenchStageKey | null>(null);
let chosenStyleId = $state<string | null>(null);
let noteOpen = $state(false),
noteText = $state(''),
noteBusy = $state(false);
async function saveNote() {
noteBusy = true;
try {
await workbenchApi.saveNote(projectId, stageKey, noteText);
await client.invalidateQueries({ queryKey: ['workbench', projectId] });
noteOpen = false;
toast.success('助手建议已保存');
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
noteBusy = false;
}
}
// Keep the chosen style selector in sync with the stage payload.
let lastStyleSync = $state('');
$effect(() => {
@@ -377,6 +395,7 @@
active="workbench"
title={overview?.project.title ?? t('workbench.title')}
subtitle={t('workbench.subtitle')}
workspace
>
{#snippet actions()}
<span class="status-chip"
@@ -388,7 +407,7 @@
{#if !$overviewQuery.data && $overviewQuery.isLoading}
<div class="page-pad">
<Skeleton height="64px" />
<div class="gap" />
<div class="gap"></div>
<Skeleton height="280px" />
</div>
{:else if !overview}
@@ -510,6 +529,37 @@
</div>
<div class="stage-panel">
{#if currentStage?.assistant_note || noteOpen}<details
class="stage-note"
open
>
<summary>助手建议 · 待整理到阶段内容</summary>
{#if noteOpen}<textarea
class="a-input"
aria-label="编辑阶段助手建议"
bind:value={noteText}
></textarea>
<div class="note-actions">
<button
class="a-btn ghost sm"
disabled={noteBusy}
onclick={() => (noteOpen = false)}>取消</button
><button
class="a-btn secondary sm"
disabled={noteBusy}
onclick={saveNote}>保存建议</button
>
</div>
{:else}<p>{currentStage?.assistant_note}</p>
<button
class="a-btn ghost sm"
disabled={busyFor(stageKey)}
onclick={() => {
noteText = currentStage?.assistant_note ?? '';
noteOpen = true;
}}>编辑建议</button
>{/if}
</details>{/if}
{#if stageKey === 'script'}
<ScriptStagePanel
stage={panelProps.stage}
@@ -1275,4 +1325,119 @@
flex: none;
}
}
.refract-project-heading {
max-width: none;
padding: 14px 22px;
height: 80px;
box-sizing: border-box;
flex-shrink: 0;
}
.project-monogram {
width: 38px;
height: 38px;
font-size: 22px;
}
.refract-project-heading h1 {
font-size: 20px;
}
.refract-project-heading p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 600px;
font-size: 11px;
}
.wb-page {
flex: 1;
min-height: 0;
width: 100%;
max-width: none;
margin: 0;
box-sizing: border-box;
padding: 0 22px 18px;
grid-template-columns: minmax(0, 1fr) 232px;
grid-template-rows: 64px minmax(0, 1fr);
gap: 12px 18px;
overflow: hidden;
}
.main-col {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.stage-head {
padding: 8px 0 12px;
flex-shrink: 0;
}
.stage-head h2 {
font-size: 18px;
}
.stage-tag {
font-size: 11px;
}
.stage-panel {
flex: 1;
min-height: 0;
overflow: auto;
background: #16161b;
}
.instruction-row {
order: 10;
flex-shrink: 0;
margin: 10px 0 0;
padding: 10px;
background: #18171d;
}
.side-col {
height: 100%;
min-height: 0;
overflow: auto;
padding: 0;
gap: 12px;
}
.side-card {
background: #16161b;
padding: 14px;
border-radius: 10px;
}
.stage-note {
padding: 14px;
border-bottom: 1px solid #a98bff25;
background: #a98bff05;
font-size: 12px;
}
.stage-note summary {
color: #c4b5fd;
cursor: pointer;
font-size: 11px;
}
.stage-note p {
white-space: pre-wrap;
line-height: 1.8;
overflow-wrap: anywhere;
}
.stage-note textarea {
min-height: 120px;
resize: vertical;
margin-top: 12px;
}
.note-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 10px;
}
@media (max-width: 1100px) {
.wb-page {
grid-template-columns: minmax(0, 1fr) 210px;
padding: 0 14px 12px;
gap: 10px 12px;
}
.refract-project-heading {
padding: 12px 14px;
}
}
</style>
+32 -3
View File
@@ -23,13 +23,13 @@
}
</script>
<AppShell active="workflows" title="模型与工作流">
<AppShell active="workflows" title="工作流目录" workspace>
<div class="workflows-page">
<header>
<div>
<div class="eyebrow">YOUR CREATIVE MATERIALS</div>
<h1>创作的原材料。</h1>
<p>素材给灵感一个起点,工具让它走得更远。</p>
<h1>工作流目录</h1>
<p>按用途挑选工具,未完成配置的工作流先到管理页核对。</p>
</div>
<button class="a-btn secondary" onclick={() => (manage = !manage)}
>{manage ? '返回工具目录' : '导入与管理'} ↗</button
@@ -120,4 +120,33 @@
padding: 0 20px 16px;
overflow: hidden;
}
.workflows-page {
max-width: none;
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
header {
margin-bottom: 16px;
}
h1 {
font-size: 24px;
margin: 5px 0;
}
header p {
font-size: 11px;
}
.resource-tabs {
margin-bottom: 12px;
}
.catalogue-frame {
flex: 1;
height: auto;
min-height: 0;
border-radius: 10px;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
## 第五版操作设计实装
白栖知在 HEL-665 确认第五版交互样本后,总管亲自接入正式功能。实现以项目既有用途组织页面。
- 创作台以作品画布为主,提示词保留在固定底座内滚动。生成历史、作品详情、素材和完整设置分别打开;常用参数在触发位置展开并允许取消。多件输出只切换本次生成作品,不插入历史。
- 工作流的输入、端点、显示结果相互独立。草稿按创作类型、项目来源和工作流保存到当前浏览器会话;历史复用同步切换工作流和端点。上传与提交期间暂不切换工作流。
- 智能体把会话与可编辑成果并排显示。输入与附件按角色和会话缓存,编辑成果按消息缓存。故事保存为项目待确认草稿;其他助手的自由文本保存为对应阶段的助手建议。
- 助手建议使用现有阶段 JSON 存储,不需要数据库迁移。建议保存不改变阶段状态;确认时校验并只快照正式结构化内容,恢复快照保留后续建议。正式生成后的阶段内容保留原建议。
- 项目固定五阶段导航、内容区与底部确认操作;素材、工作流、模型和设置采用图库、目录与集中列表。模型编辑使用统一模态焦点规则,嵌套弹窗只由最上层处理键盘。
## 验证
在隔离后端连接模拟 ComfyUI 和大模型,验证真实 HTTP、上传、队列、事件和项目保存链路,不调用收费模型。四种桌面尺寸为 1600×1000、1440×900、1280×720、960×640。主回归涵盖 49 项操作,另补竖图、遮罩、不同端点复用、异步任务与会话隔离检查。阶段生命周期与建议单元测试、现有前端校验、类型检查和静态构建均需通过后部署。
## 范围与交付
不新增切镜复刻、音乐 MV 或漫剧十二步,不批量启用未映射工作流。三维搭景与工作流试验台保留原有能力,本次未计为完成复刻。服务器用户数据和密钥配置不改;部署保留静态页面、源代码、workbench 路由备份及回滚脚本,原演示样本保留。代码继续更新现有合并请求,合并和归档等待用户验收。