HEL-665: implement v5 creation hierarchy and project-bound agent drafts
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
## 第五版操作设计实装
|
||||
|
||||
白栖知在 HEL-665 确认第五版交互样本后,总管亲自接入正式功能。实现以项目既有用途组织页面。
|
||||
|
||||
- 创作台以作品画布为主,提示词保留在固定底座内滚动。生成历史、作品详情、素材和完整设置分别打开;常用参数在触发位置展开并允许取消。多件输出只切换本次生成作品,不插入历史。
|
||||
- 工作流的输入、端点、显示结果相互独立。草稿按创作类型、项目来源和工作流保存到当前浏览器会话;历史复用同步切换工作流和端点。上传与提交期间暂不切换工作流。
|
||||
- 智能体把会话与可编辑成果并排显示。输入与附件按角色和会话缓存,编辑成果按消息缓存。故事保存为项目待确认草稿;其他助手的自由文本保存为对应阶段的助手建议。
|
||||
- 助手建议使用现有阶段 JSON 存储,不需要数据库迁移。建议保存不改变阶段状态;确认时校验并只快照正式结构化内容,恢复快照保留后续建议。正式生成后的阶段内容保留原建议。
|
||||
- 项目固定五阶段导航、内容区与底部确认操作;素材、工作流、模型和设置采用图库、目录与集中列表。模型编辑使用统一模态焦点规则,嵌套弹窗只由最上层处理键盘。
|
||||
|
||||
## 验证
|
||||
|
||||
在隔离后端连接模拟 ComfyUI 和大模型,验证真实 HTTP、上传、队列、事件和项目保存链路,不调用收费模型。四种桌面尺寸为 1600×1000、1440×900、1280×720、960×640。主回归涵盖 49 项操作,另补竖图、遮罩、不同端点复用、异步任务与会话隔离检查。阶段生命周期与建议单元测试、现有前端校验、类型检查和静态构建均需通过后部署。
|
||||
|
||||
## 范围与交付
|
||||
|
||||
不新增切镜复刻、音乐 MV 或漫剧十二步,不批量启用未映射工作流。三维搭景与工作流试验台保留原有能力,本次未计为完成复刻。服务器用户数据和密钥配置不改;部署保留静态页面、源代码、workbench 路由备份及回滚脚本,原演示样本保留。代码继续更新现有合并请求,合并和归档等待用户验收。
|
||||
Reference in New Issue
Block a user