生图模型可选(火山/gpt-image)+ 模特上身图提示词强化 + 多会话等改动

本轮(生图模型选择 + 火山接入):
- 工作室新增「生图模型」选择器(模特上身图/平台套图头部 chip + 图片创作底部 Pill),
  默认火山 Seedream,可切 gpt-image-2;选择写入 localStorage,下次进页面读回
- 后端 resolve_image_model 解析所选模型;enqueue_standalone_images 接 image_model
- worker 按模型能力分流:有 image_edit(gpt-image)走多图编辑;无(火山)走
  image_generation(image=参考图);新增 _ratio_to_volcano_size 让火山按比例出图
  → 内衣等敏感品类用火山可绕开 gpt-image 的 sexual 内容审核

模特上身图提示词:
- 穿戴/非穿戴分流、按 index 变化动作场景镜头、负面词尾接、多图参考序号自适应
- _product_reference_urls:商品参考真实上传图优先、排除 AI 生成图、可多张

其他(并入此前各会话未提交改动):
- 图片创作多会话(ImageConversation + migration 0019)、任务中心按类型过滤
- accounts/projects/assets/team/auth 等零散调整、相关测试
- 测试脚本(tryon_*.py)、测试清单(core/bug/*.xlsx)
This commit is contained in:
zyc
2026-06-27 14:00:28 +08:00
parent 0a519ca892
commit ce7eaefe1d
21 changed files with 1279 additions and 158 deletions
+425 -105
View File
@@ -13,6 +13,7 @@ import {
LayoutGrid,
List,
MoreHorizontal,
Pencil,
Plus,
Quote,
RefreshCw,
@@ -23,7 +24,7 @@ import {
WandSparkles,
X
} from "lucide-react";
import type { AITask, Asset, ModelConfig, ModelEntity, Product } from "../types";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product } from "../types";
import { api } from "../api";
import { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } from "../components/loading";
@@ -33,13 +34,12 @@ import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import "../ai-tools-page.css";
const TASK_TYPE_LABEL: Record<string, string> = {
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
// 而 task_type 区分不了——cover 与 image 都是 product_image)
const MODE_LABEL: Record<string, string> = {
model: "模特上身图",
platform: "平台套图",
image: "图片创作",
model_photo: "模特上身图",
platform_cover: "平台套图",
image_optimize: "图片创作"
cover: "平台套图",
image: "图片创作"
};
const STATUS_LABEL: Record<string, string> = {
@@ -94,7 +94,8 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
let alive = true;
setTasksLoading(true);
api.aiTasks().then((res) => { if (alive) setAiTasks(res.results || []); }).catch(() => {}).finally(() => { if (alive) setTasksLoading(false); });
api.assetsPage({ asset_type: "image", source: "ai_generated", pageSize: 200 })
// 任务中心 = 生成历史:要看全部生成图(含未加入资产库的),故 in_library: "all" 旁路过滤
api.assetsPage({ asset_type: "image", source: "ai_generated", in_library: "all", pageSize: 200 })
.then((res) => { if (alive) setAssets(res.results); })
.catch(() => { if (alive) setAssets([]); });
return () => { alive = false; };
@@ -133,16 +134,44 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
}
];
// 任务中心只看「工作台图片生成」(mode∈model/cover/image —— 能分模特上身图/平台套图/图片创作;
// 注意 mode 也被脚本 agent 复用为 auto/theme/revise,故必须用白名单过滤,别把脚本任务混进来)。
// 每张图是一个独立 AITask,同次提交共享 batch_id → 按 batch_id 归成「批次卡」;旧图无 batch_id 则各自成单。
type TaskBatch = {
key: string; batchId: string | null; firstTaskId: string; label: string; mode: string;
count: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
};
const taskBatches = useMemo<TaskBatch[]>(() => {
const groups = new Map<string, AITask[]>();
for (const t of aiTasks) {
if (!t.mode || !MODE_LABEL[t.mode]) continue; // 只保留图片生成模式(model/cover/image)
const key = t.batch_id || t.id;
const arr = groups.get(key);
if (arr) arr.push(t);
else groups.set(key, [t]);
}
const out: TaskBatch[] = [];
for (const [key, tasks] of groups) {
const first = tasks[0];
const pills = tasks.map((t) => statusPill(t.status));
const status: "info" | "ok" | "err" = pills.some((p) => p === "info") ? "info" : pills.some((p) => p === "ok") ? "ok" : "err";
const created = tasks.reduce((m, t) => ((t.created_at || "") > m ? (t.created_at || "") : m), "");
const cover = tasks.map((t) => taskImage[t.id]).find(Boolean) || "";
out.push({ key, batchId: first.batch_id || null, firstTaskId: first.id, label: MODE_LABEL[first.mode!], mode: first.mode!, count: tasks.length, status, created_at: created, cover });
}
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
return out;
}, [aiTasks, taskImage]);
const counts = useMemo(() => {
const acc = { gen: 0, ok: 0, err: 0 };
for (const task of aiTasks) {
const pill = statusPill(task.status);
if (pill === "ok") acc.ok += 1;
else if (pill === "err") acc.err += 1;
else if (pill === "info") acc.gen += 1;
for (const b of taskBatches) {
if (b.status === "ok") acc.ok += 1;
else if (b.status === "err") acc.err += 1;
else acc.gen += 1;
}
return acc;
}, [aiTasks]);
}, [taskBatches]);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
@@ -154,6 +183,25 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
const [taskPage, setTaskPage] = useState(1);
// 批次详情弹窗:点批次卡 → 按 metadata.batch_id 拉该批全部图(含未入库)→ 网格展示,点图放大
const [openBatch, setOpenBatch] = useState<TaskBatch | null>(null);
const [batchImgs, setBatchImgs] = useState<Asset[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
useEffect(() => {
if (!openBatch) { setBatchImgs([]); return; }
let alive = true;
setBatchLoading(true);
// 有 batch_id 按批取整批;旧图无 batch_id 则按 origin_task 取该任务那张
const params = openBatch.batchId
? { meta: { batch_id: openBatch.batchId }, in_library: "all", asset_type: "image", pageSize: 50 }
: { origin_task: openBatch.firstTaskId, in_library: "all", asset_type: "image", pageSize: 50 };
api.assetsPage(params)
.then((res) => { if (alive) setBatchImgs(res.results); })
.catch(() => { if (alive) setBatchImgs([]); })
.finally(() => { if (alive) setBatchLoading(false); });
return () => { alive = false; };
}, [openBatch]);
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
@@ -161,22 +209,21 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
return () => document.removeEventListener("click", close);
}, [openChip]);
const typeOptions = Array.from(new Set(aiTasks.map((t) => t.task_type).filter(Boolean)));
const typeOptions = Array.from(new Set(taskBatches.map((b) => b.label).filter(Boolean)));
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
{ value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" }
];
const visible = aiTasks.filter((task) => {
const pill = statusPill(task.status);
if (filter === "gen" && pill !== "info") return false;
if (filter === "ok" && pill !== "ok") return false;
if (filter === "err" && pill !== "err") return false;
if (typeFilter && task.task_type !== typeFilter) return false;
if (timeFilter !== "all" && task.created_at) {
const days = (Date.now() - new Date(task.created_at).getTime()) / 86400000;
const visible = taskBatches.filter((batch) => {
if (filter === "gen" && batch.status !== "info") return false;
if (filter === "ok" && batch.status !== "ok") return false;
if (filter === "err" && batch.status !== "err") return false;
if (typeFilter && batch.label !== typeFilter) return false;
if (timeFilter !== "all" && batch.created_at) {
const days = (Date.now() - new Date(batch.created_at).getTime()) / 86400000;
if (days > Number(timeFilter)) return false;
}
if (query) {
const hay = `${TASK_TYPE_LABEL[task.task_type] || task.task_type} ${task.task_type} ${task.id}`.toLowerCase();
const hay = `${batch.label} ${batch.key}`.toLowerCase();
if (!hay.includes(query.toLowerCase())) return false;
}
return true;
@@ -228,13 +275,13 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
<div className="section-h">
<h2></h2>
<span className="sub-mono">
// {aiTasks.length} · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
// {taskBatches.length} · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
</span>
</div>
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
<div className="tabs" id="tc-tabs">
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}> <span className="count">{aiTasks.length}</span></div>
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}> <span className="count">{taskBatches.length}</span></div>
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}> <span className="count">{counts.gen}</span></div>
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}> <span className="count">{counts.ok}</span></div>
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}> <span className="count">{counts.err}</span></div>
@@ -259,7 +306,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{typeFilter ? TASK_TYPE_LABEL[typeFilter] || typeFilter : "任务类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
<span className="chip-label">{typeFilter || "任务类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setOpenChip(""); }}>
@@ -268,7 +315,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((t) => (
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{TASK_TYPE_LABEL[t] || t}
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{t}
</div>
))}
</div>
@@ -293,7 +340,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
</div>
<div className="result-meta">
// 显示 {paged.length} / {visible.length} 个任务
// 显示 {paged.length} / {visible.length}
</div>
{tasksLoading && aiTasks.length === 0 ? (
@@ -310,19 +357,19 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
</div>
) : view === "grid" ? (
<div className="history-grid">
{paged.map((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
const img = taskImage[task.id];
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<article className="task-card history-card" key={task.id}>
<div className={`placeholder${img ? " has-img" : ""}`}>{img ? <img src={img} alt={typeLabel} loading="lazy" decoding="async" /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}</div>
<article className="task-card history-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
onClick={() => setOpenBatch(batch)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<div className={`placeholder${batch.cover ? " has-img" : ""}`}>{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label}</span>}</div>
<div className="history-body">
<div className="history-name">{typeLabel}</div>
<div className="history-type">// {task.task_type}</div>
<div className="history-name">{batch.label}</div>
<div className="history-type">// {batch.count} 张</div>
<div className="history-foot">
<span className="mono">{(task.created_at || "").slice(0, 10)}</span>
<span className={`pill ${pill}`}><span className="dot" />{statusText(task.status)}</span>
<span className="mono">{(batch.created_at || "").slice(0, 10)}</span>
<span className={`pill ${batch.status}`}><span className="dot" />{statusLabel}</span>
</div>
</div>
</article>
@@ -342,44 +389,44 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
</tr>
</thead>
<tbody>
{paged.map((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
const img = taskImage[task.id];
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<tr key={task.id}>
<tr key={batch.key} role="button" tabIndex={0} title="查看本批图片" style={{ cursor: "pointer" }}
onClick={() => setOpenBatch(batch)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<td>
<div className="task-name-cell">
<div className={`placeholder task-thumb${img ? " has-img" : ""}`}>
{img ? <img src={img} alt={typeLabel} loading="lazy" decoding="async" /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}
<div className={`placeholder task-thumb${batch.cover ? " has-img" : ""}`}>
{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label.slice(0, 2)}</span>}
</div>
<div>
<div className="task-name">{typeLabel}</div>
<div className="task-sub">// {task.task_type}</div>
<div className="task-name">{batch.label}</div>
<div className="task-sub">// {batch.count} 张</div>
</div>
</div>
</td>
<td>
{pill === "info" ? (
{batch.status === "info" ? (
<div className="task-list-prog">
<div className="bar">
<span style={{ width: "60%" }} />
</div>
<span className="pct">60%</span>
<span className="pct"></span>
</div>
) : (
<span className="muted-2 mono" style={{ fontSize: 12 }}>
{pill === "ok" ? "已完成" : "—"}
{batch.status === "ok" ? "已完成" : "—"}
</span>
)}
</td>
<td>
<span className={`pill ${pill}`}>
<span className={`pill ${batch.status}`}>
<span className="dot" />
{statusText(task.status)}
{statusLabel}
</span>
</td>
<td className="muted-2 mono" style={{ fontSize: 12 }}>{(task.created_at || "").slice(0, 10)}</td>
<td className="muted-2 mono" style={{ fontSize: 12 }}>{(batch.created_at || "").slice(0, 10)}</td>
<td />
</tr>
);
@@ -390,6 +437,46 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
{openBatch && createPortal(
<div className="modal-bg show" onClick={() => setOpenBatch(null)}>
<div className="modal with-corners" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 760, width: "92%" }}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="modal-h">
<div className="ic-m"><LayoutGrid size={17} /></div>
<div className="ti">{openBatch.label}<span>// {openBatch.count} 张 · {(openBatch.created_at || "").slice(0, 10)}</span></div>
<span style={{ flex: 1 }} />
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}><X size={16} /></button>
</div>
<div className="modal-b">
{batchLoading ? (
<div className="task-empty"><div className="mono">// LOADING…</div></div>
) : batchImgs.length === 0 ? (
<div className="task-empty"><div className="mono">// NO IMAGE</div><div>这批没有可显示的图片</div></div>
) : (
<div className="gen-images" style={{ "--cols": Math.min(4, batchImgs.length), "--ratio": "1 / 1" } as React.CSSProperties}>
{batchImgs.map((a, i) => {
const u = a.files?.find((f) => f.preview_url)?.preview_url || a.files?.[0]?.preview_url || "";
return (
<div className="gen-image" key={a.id}>
{u ? (
<img className="gen-image-img" src={u} alt={a.name} loading="lazy" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: u, name: a.name })} />
) : (
<div className="placeholder"><span className="ph-frame">#{i + 1}</span></div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>,
document.body
)}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</div>
);
}
@@ -450,16 +537,6 @@ const IMAGE_SUGGESTIONS = [
"电影感都市夜景,街道湿漉漉反射霓虹,4K 高清"
];
/* 图片创作 · 风格胶囊(基线 image-optimize STYLES) */
const STYLE_OPTIONS = [
{ id: "auto", label: "默认" },
{ id: "realistic", label: "写实" },
{ id: "cinematic", label: "电影感" },
{ id: "anime", label: "动漫" },
{ id: "oil", label: "油画" },
{ id: "cn-ink", label: "国风水墨" }
];
/* 模特上身图 · 真人模特默认占位卡(基线 model-photo Ava/Luna/Mia/Zoe) */
const FALLBACK_MODELS = [
{ id: "m1", name: "Ava", tag: "亚洲·25岁·清新" },
@@ -503,6 +580,8 @@ type GenBatch = {
modelName?: string;
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
platformIds?: string[];
/** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */
refs?: { name: string; url: string }[];
};
export function ImageWorkbenchPage({
@@ -521,7 +600,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string }) => Promise<{ assets: Asset[] } | null>;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[] }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
@@ -533,13 +612,13 @@ export function ImageWorkbenchPage({
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
const product = products.find((item) => item.id === productId) || products[0];
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
const [ratio, setRatio] = useState(meta.ratio);
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
const [ratioManual, setRatioManual] = useState(false);
const [ratioW, setRatioW] = useState("");
const [ratioH, setRatioH] = useState("");
const [style, setStyle] = useState("auto");
// 生图模型选择:默认火山(内衣等敏感品类不被审核拦,还原也更好),可切 gpt-image-2。持久化到 localStorage。
const [genModel, setGenModel] = useState<string>(() => {
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
@@ -564,7 +643,21 @@ export function ImageWorkbenchPage({
const [openMore, setOpenMore] = useState("");
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
const [batches, setBatches] = useState<GenBatch[]>([]);
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
/* ── 图片创作「对话」(真实体,后端 ImageConversation)──
conversations = 左栏会话列表;activeConvId = 当前选中对话(空 = 还没建,首次发送时后端自动开)。
activeConvRef 让 startBatch 在不进 deps 的情况下读到最新 active id。 */
const [conversations, setConversations] = useState<ImageConversation[]>([]);
const [activeConvId, setActiveConvId] = useState<string>("");
const activeConvRef = useRef<string>("");
useEffect(() => { activeConvRef.current = activeConvId; }, [activeConvId]);
const [convLoading, setConvLoading] = useState(false);
// 对话操作失败的可见提示(替代原来的静默吞错)
const [convError, setConvError] = useState("");
// 重命名:正在改名的对话 id + 草稿
const [renamingId, setRenamingId] = useState("");
const [renameDraft, setRenameDraft] = useState("");
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 生成结果图片放大预览
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
@@ -589,9 +682,10 @@ export function ImageWorkbenchPage({
return p.cover_preview_url || primary?.preview_url || "";
};
function pickReference(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setRefImage({ name: file.name, url: URL.createObjectURL(file) });
const files = Array.from(event.target.files || []);
if (!files.length) return;
// 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传
setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]);
event.target.value = "";
}
@@ -627,8 +721,10 @@ export function ImageWorkbenchPage({
useEffect(() => { loadModels(); }, [loadModels]);
useEffect(() => {
if (product) setPrompt(meta.promptTemplate(product.title));
// mode 或商品切换都重置 prompt 与默认比例
// image 模式默认留空(不预填模板);模特/平台仍随商品预填模板
if (mode === "image") setPrompt("");
else if (product) setPrompt(meta.promptTemplate(product.title));
// mode 或商品切换都重置默认比例
setRatio(meta.ratio);
setRatioManual(false);
setRatioW("");
@@ -682,6 +778,133 @@ export function ImageWorkbenchPage({
}
}
/* ════════ 图片创作「对话」实体的增删改查 + 历史回填 ════════ */
// 后端对话任务流 → 前端批次:按 batch_id 把同一次提交的多张图归到一个 GenBatch
function batchesFromConvTasks(tasks: ImageConversationTask[]): GenBatch[] {
const groups = new Map<string, ImageConversationTask[]>();
for (const t of tasks) {
const key = t.batch_id || t.id; // 老任务可能无 batch_id,各自成批
const list = groups.get(key) || [];
list.push(t);
groups.set(key, list);
}
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
const assets = list.flatMap((t) => t.assets || []);
const allTerminal = list.every((t) => TERMINAL.has(t.status));
const status: GenBatch["status"] = assets.length > 0 ? "done" : allTerminal ? "failed" : "generating";
const withId = assets.filter((a) => a.id);
result.push({
id: key,
prompt: list[0]?.prompt || "",
ratio: list[0]?.ratio || meta.ratio,
count: list.length,
status,
results: assets,
adopted: withId.length > 0 && withId.every((a) => a.in_library),
ts: new Date(list[0]?.created_at || Date.now()).getTime(),
// 该批用过的参考图(后端按 reference_image_ids 解析回 {name,url}),切换/刷新后仍可见
refs: (list[0]?.reference_images || []).length ? list[0].reference_images : undefined,
});
}
// 旧批次在上、新批次在下(对话流自上而下时间序)
return result.sort((a, b) => a.ts - b.ts);
}
// 拉某对话的历史批次并回显;非终态批次继续轮询补齐
const loadConvBatches = useCallback(async (convId: string) => {
try {
const res = await api.conversationTasks(convId);
const next = batchesFromConvTasks(res.tasks);
setBatches(next);
// 仍在跑的批次(刷新时 worker 还没出完)继续轮询补齐
if (onResume) {
for (const b of next.filter((x) => x.status === "generating")) {
const ids = res.tasks.filter((t) => (t.batch_id || t.id) === b.id).map((t) => t.id);
if (!ids.length) continue;
onResume(mode, ids).then((r) => {
if (!r?.assets) return;
setBatches((prev) => prev.map((x) => (x.id === b.id ? { ...x, status: "done", results: r.assets } : x)));
});
}
}
} catch {
setBatches([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, onResume]);
// 切换对话:置为 active 并回填它的批次
const selectConversation = useCallback((convId: string) => {
if (convId === activeConvRef.current) return;
setActiveConvId(convId);
setRenamingId("");
void loadConvBatches(convId);
}, [loadConvBatches]);
// 新对话:后端建一条 → 置顶列表 → 设为 active → 清空批次流
async function handleNewConversation() {
try {
const conv = await api.createConversation({ mode, product: product?.id || null });
setConvError("");
setConversations((prev) => [conv, ...prev]);
setActiveConvId(conv.id);
setBatches([]);
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
setPickedIds([]);
} catch (err) {
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
setConvError(err instanceof Error ? err.message : "新建对话失败,请稍后重试");
}
}
// 提交重命名
async function commitRename(convId: string) {
const title = renameDraft.trim();
setRenamingId("");
if (!title) return;
setConversations((prev) => prev.map((c) => (c.id === convId ? { ...c, title } : c)));
try { await api.renameConversation(convId, title); } catch { void loadConversations(); }
}
// 删除对话(软删):从列表移除;删的是当前对话则切到剩下第一条或清空
async function handleDeleteConversation(convId: string) {
const remaining = conversations.filter((c) => c.id !== convId);
setConversations(remaining);
if (convId === activeConvId) {
const nextActive = remaining[0]?.id || "";
setActiveConvId(nextActive);
if (nextActive) void loadConvBatches(nextActive);
else setBatches([]);
}
try { await api.deleteConversation(convId); } catch { void loadConversations(); }
}
// 列表加载:image 模式才用对话(model/cover 走商品空间布局)。
// autoSelect=true(首次进入):自动选最近一条并回填历史;false(首发后只刷新列表):不动当前对话/批次。
const loadConversations = useCallback(async (autoSelect = true) => {
if (mode !== "image") return;
setConvLoading(true);
try {
const res = await api.listConversations(mode);
setConversations(res.results);
if (autoSelect && res.results.length && !activeConvRef.current) {
setActiveConvId(res.results[0].id);
void loadConvBatches(res.results[0].id);
}
} catch {
setConversations([]);
} finally {
setConvLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, loadConvBatches]);
// 只在挂载 / 切 mode 时加载一次对话列表(不依赖 callback 身份,否则 onResume 每次渲染变更会触发反复重拉)
useEffect(() => { void loadConversations(); }, [mode]); // eslint-disable-line react-hooks/exhaustive-deps
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
async function startBatch(opts: {
prompt: string;
@@ -692,6 +915,8 @@ export function ImageWorkbenchPage({
modelId?: string;
modelName?: string;
platformIds?: string[];
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
refs?: { name: string; url: string; file?: File; assetId?: string }[];
}) {
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const newBatch: GenBatch = {
@@ -707,11 +932,36 @@ export function ImageWorkbenchPage({
productTitle: opts.productTitle,
modelId: opts.modelId,
modelName: opts.modelName,
platformIds: opts.platformIds
platformIds: opts.platformIds,
// 批次头回显「参考了哪些图」(只存名+预览,不存 file)
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url }))
};
setBatches((prev) => [...prev, newBatch]);
try {
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel });
// 先把参考图上传成 Asset,拿到 id 列表带给后端 → 生成时真正作多图参考(image_edit)。
// 上传任一失败不阻断:跳过该张,有几张算几张。
let referenceImageIds: string[] | undefined;
if (opts.refs?.length) {
const ids = await Promise.all(
opts.refs.map(async (r) => {
if (r.assetId) return r.assetId;
if (!r.file) return null;
const form = new FormData();
form.append("file", r.file);
form.append("asset_type", "image");
return api.uploadAsset(form).then((a) => a.id).catch(() => null);
})
);
referenceImageIds = ids.filter((x): x is string => !!x);
}
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
setActiveConvId(convId);
void loadConversations(false);
}
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b));
persistBatches(next);
@@ -728,6 +978,18 @@ export function ImageWorkbenchPage({
async function runGenerate() {
if (!canGenerate) return;
// 图片创作:生成前先把对话「坐实」到侧栏(若还没有),这样这条长达 ~60s 的生成在进行中也能切走/切回——
// 否则对话要等生成返回后才登记进列表,生成中根本看不到这条会话 → 切走就找不回 → loading 状态像丢了。
if (mode === "image" && !activeConvRef.current) {
try {
const conv = await api.createConversation({ mode, title: prompt.trim().slice(0, 24) || undefined });
activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到
setActiveConvId(conv.id);
setConversations((prev) => [conv, ...prev]);
} catch {
/* 建会话失败:回退到后端自动建(仍能生成,只是生成中暂不可切回) */
}
}
const base = {
prompt: prompt.trim(),
ratio,
@@ -747,8 +1009,12 @@ export function ImageWorkbenchPage({
void startBatch({
...base,
modelId: mode === "model" ? pickedIds[0] : undefined,
modelName: mode === "model" ? pickedModelName : undefined
modelName: mode === "model" ? pickedModelName : undefined,
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
refs: mode === "image" && refImages.length ? refImages : undefined
});
// 参考图已交给本批,清空输入栏待下次(批次头会保留这次用过的参考图)
if (mode === "image") setRefImages([]);
}
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
@@ -850,6 +1116,8 @@ export function ImageWorkbenchPage({
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
useEffect(() => {
let cancelled = false;
// image 模式的历史现由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
if (mode === "image") return;
// 1) 多批次结果回显
try {
const raw = localStorage.getItem(batchKey);
@@ -1128,26 +1396,68 @@ export function ImageWorkbenchPage({
</button>
</div>
<button className="ic-new-conv" type="button" onClick={() => { setBatches([]); persistBatches([]); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
<button className="ic-new-conv" type="button" onClick={handleNewConversation}>
<Plus size={13} />
</button>
<div className="ic-side-sec"></div>
<div className="ic-conv-list">
<div className="ic-conv-item active">
<div className="thumb default">
<ImagePlus size={13} />
</div>
<span className="nm"></span>
</div>
</div>
{convError && <div className="ic-conv-error">{convError}</div>}
<div className="ic-side-sec"></div>
<div className="ic-conv-list">
<div className="ic-conv-empty">
<br />
<span className="mono">// NO HISTORY</span>
</div>
{conversations.length === 0 ? (
<div className="ic-conv-empty">
{convLoading ? "加载中…" : "还没有最近会话"}
<br />
<span className="mono">// NO HISTORY</span>
</div>
) : (
conversations.map((conv) => (
<div
className={`ic-conv-item ${conv.id === activeConvId ? "active" : ""}`}
key={conv.id}
role="button"
tabIndex={0}
onClick={() => selectConversation(conv.id)}
onKeyDown={(e) => { if (e.key === "Enter") selectConversation(conv.id); }}
>
<div className={`thumb ${conv.id === activeConvId ? "default" : ""}`}>
<ImagePlus size={13} />
</div>
{renamingId === conv.id ? (
<input
className="ic-conv-rename"
autoFocus
value={renameDraft}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameDraft(e.target.value)}
onBlur={() => commitRename(conv.id)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitRename(conv.id);
if (e.key === "Escape") setRenamingId("");
}}
/>
) : (
<span className="nm">{conv.title || "未命名创作"}</span>
)}
<span className="ic-conv-acts">
<button
type="button"
title="重命名"
onClick={(e) => { e.stopPropagation(); setRenamingId(conv.id); setRenameDraft(conv.title); }}
>
<Pencil size={12} />
</button>
<button
type="button"
title="删除对话"
onClick={(e) => { e.stopPropagation(); handleDeleteConversation(conv.id); }}
>
<Trash2 size={12} />
</button>
</span>
</div>
))
)}
</div>
</aside>
@@ -1165,6 +1475,16 @@ export function ImageWorkbenchPage({
</span>
<div className="pt">
<div className="pt-text">{batch.prompt}</div>
{batch.refs && batch.refs.length > 0 && (
<div className="pt-refs">
{batch.refs.map((r, i) => (
<span className="pt-ref" key={`${r.name}-${i}`} title={r.name}>
<img src={r.url} alt={r.name} />
</span>
))}
<span className="pt-ref-label"> ×{batch.refs.length}</span>
</div>
)}
<div className="pt-tags">
<span className="meta-chip">{batch.ratio}</span>
<span className="sep">·</span>
@@ -1227,17 +1547,17 @@ export function ImageWorkbenchPage({
<div className="ic-input-wrap">
<div className="ic-input">
<div className="ic-input-top">
<button className="add-btn" type="button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
<button className="add-btn" type="button" title="上传参考图(可多张)" onClick={() => refInputRef.current?.click()}>
<Plus size={22} />
</button>
<input ref={refInputRef} type="file" accept="image/*" hidden onChange={pickReference} />
{refImage && (
<span className="meta-chip" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={refImage.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
{refImage.name.slice(0, 16)}
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImage(null)}>×</button>
<input ref={refInputRef} type="file" accept="image/*" multiple hidden onChange={pickReference} />
{refImages.map((img, index) => (
<span className="meta-chip" key={`${img.name}-${index}`} style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={img.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
{img.name.slice(0, 16)}
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImages((prev) => prev.filter((_, i) => i !== index))}>×</button>
</span>
)}
))}
</div>
<textarea
className="ic-input-text"
@@ -1246,18 +1566,18 @@ export function ImageWorkbenchPage({
placeholder="输入想法、剧本或上传参考,和 Agent 一起创作"
/>
<div className="ic-input-bottom">
<Pill
label="模型"
value={GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}
options={GEN_MODEL_OPTIONS.map((o) => ({ id: o.value, label: o.label }))}
onSelect={setGenModel}
/>
<Pill
label="比例"
value={ratio}
options={RATIO_OPTIONS.map((value) => ({ id: value, label: value }))}
onSelect={setRatio}
/>
<Pill
label="风格"
value={STYLE_OPTIONS.find((s) => s.id === style)?.label || "默认"}
options={STYLE_OPTIONS}
onSelect={setStyle}
/>
<Pill
label="张数"
value={count}
+3 -3
View File
@@ -20,7 +20,7 @@ type LoginProgress = "checking" | "entering";
const LOGIN_PROGRESS_COPY: Record<LoginProgress, string> = {
checking: "正在验证用户名和密码…",
entering: "登录成功,正在进入 Airshelf"
entering: "登录成功,正在进入工作台…"
};
type FieldErrors = {
@@ -50,7 +50,7 @@ export function AuthScreen({
}: {
initialMode: AuthMode;
onModeChange: (mode: AuthMode) => void;
onAuthed: (payload: { token: string; user: User; team: Team; remember?: boolean }) => void | Promise<void>;
onAuthed: (payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) => void | Promise<void>;
}) {
const remembered = getRemember();
const [mode, setMode] = useState<AuthMode>(initialMode);
@@ -438,7 +438,7 @@ export function AuthScreen({
{loginProgress && !error && (
<div className="login-progress" role="status" aria-live="polite">
<span className="login-progress-spinner" aria-hidden="true"></span>
<span>{inviteState.kind === "create_team" ? "正在创建团队,进入 Airshelf…" : "正在加入团队,进入 Airshelf…"}</span>
<span>{inviteState.kind === "create_team" ? "正在创建团队,进入工作台…" : "正在加入团队,进入工作台…"}</span>
</div>
)}
{error && <div className="form-error" role="alert">{error}</div>}
+1 -1
View File
@@ -115,7 +115,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
const [cuName, setCuName] = useState("");
const [cuRole, setCuRole] = useState("member");
const [cuDaily, setCuDaily] = useState("100");
const [cuMonthly, setCuMonthly] = useState("2000");
const [cuMonthly, setCuMonthly] = useState("100"); // 新成员默认月度限额 100(PMC#3)
const [cuTotal, setCuTotal] = useState("-1");
// 编辑成员