|
|
|
@@ -635,6 +635,7 @@ export function ImageWorkbenchPage({
|
|
|
|
|
navigate,
|
|
|
|
|
onGenerate,
|
|
|
|
|
onResume,
|
|
|
|
|
imageProductId,
|
|
|
|
|
initialProductId,
|
|
|
|
|
onProductChange,
|
|
|
|
|
unreadByProduct,
|
|
|
|
@@ -648,6 +649,8 @@ export function ImageWorkbenchPage({
|
|
|
|
|
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; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
|
|
|
|
|
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
|
|
|
|
/** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */
|
|
|
|
|
imageProductId?: string;
|
|
|
|
|
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
|
|
|
|
initialProductId?: string;
|
|
|
|
|
/** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */
|
|
|
|
@@ -662,10 +665,15 @@ export function ImageWorkbenchPage({
|
|
|
|
|
const meta = MODE_META[mode];
|
|
|
|
|
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
|
|
|
|
|
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
|
|
|
|
|
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
|
|
|
|
|
useEffect(() => { if (mode !== "image" && productId) onProductChange?.(productId); }, [mode, productId, onProductChange]);
|
|
|
|
|
// YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零
|
|
|
|
|
useEffect(() => { if (productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [productId, unreadByProduct, onProductViewed]);
|
|
|
|
|
useEffect(() => { if (mode !== "image" && productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [mode, productId, unreadByProduct, onProductViewed]);
|
|
|
|
|
const product = products.find((item) => item.id === productId) || products[0];
|
|
|
|
|
const imageProduct = products.find((item) => item.id === imageProductId);
|
|
|
|
|
const conversationScopeKey = imageProductId ? `product:${imageProductId}` : "unbound";
|
|
|
|
|
const conversationScopeLabel = imageProductId ? `当前商品 · ${imageProduct?.title || "加载中"}` : "通用创作";
|
|
|
|
|
const conversationScopeKeyRef = useRef(conversationScopeKey);
|
|
|
|
|
conversationScopeKeyRef.current = conversationScopeKey;
|
|
|
|
|
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
|
|
|
|
|
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
|
|
|
|
|
const [ratio, setRatio] = useState(meta.ratio);
|
|
|
|
@@ -722,6 +730,8 @@ export function ImageWorkbenchPage({
|
|
|
|
|
const [activeConvId, setActiveConvId] = useState<string>("");
|
|
|
|
|
const activeConvRef = useRef<string>("");
|
|
|
|
|
useEffect(() => { activeConvRef.current = activeConvId; }, [activeConvId]);
|
|
|
|
|
const conversationLoadSeqRef = useRef(0);
|
|
|
|
|
const conversationBatchLoadSeqRef = useRef(0);
|
|
|
|
|
const [convLoading, setConvLoading] = useState(false);
|
|
|
|
|
// 对话操作失败的可见提示(替代原来的静默吞错)
|
|
|
|
|
const [convError, setConvError] = useState("");
|
|
|
|
@@ -909,6 +919,8 @@ export function ImageWorkbenchPage({
|
|
|
|
|
status,
|
|
|
|
|
results: assets,
|
|
|
|
|
ts: new Date(live[0]?.created_at || Date.now()).getTime(),
|
|
|
|
|
productId: live[0]?.product_id || undefined,
|
|
|
|
|
productTitle: products.find((item) => item.id === live[0]?.product_id)?.title,
|
|
|
|
|
refs: refSrc.length ? refSrc.map((r) => ({ name: r.name, url: r.url, assetId: r.id })) : undefined,
|
|
|
|
|
// 真 batch_id 才能作重跑归属;老任务无 batch_id 时 key=任务 id,不能带给后端
|
|
|
|
|
backendBatchId: live[0]?.batch_id || undefined,
|
|
|
|
@@ -979,8 +991,10 @@ export function ImageWorkbenchPage({
|
|
|
|
|
|
|
|
|
|
// 拉某对话的历史批次并回显;非终态批次继续轮询补齐
|
|
|
|
|
const loadConvBatches = useCallback(async (convId: string) => {
|
|
|
|
|
const batchLoadSeq = ++conversationBatchLoadSeqRef.current;
|
|
|
|
|
try {
|
|
|
|
|
const res = await api.conversationTasks(convId);
|
|
|
|
|
if (batchLoadSeq !== conversationBatchLoadSeqRef.current) return;
|
|
|
|
|
const next = batchesFromConvTasks(res.tasks);
|
|
|
|
|
setBatches(next);
|
|
|
|
|
// 仍在跑的批次(刷新时 worker 还没出完)继续轮询补齐
|
|
|
|
@@ -989,13 +1003,14 @@ export function ImageWorkbenchPage({
|
|
|
|
|
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 (batchLoadSeq !== conversationBatchLoadSeqRef.current) return;
|
|
|
|
|
if (!r?.assets) return;
|
|
|
|
|
setBatches((prev) => prev.map((x) => (x.id === b.id ? { ...x, status: "done", results: r.assets } : x)));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setBatches([]);
|
|
|
|
|
if (batchLoadSeq === conversationBatchLoadSeqRef.current) setBatches([]);
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [mode, onResume]);
|
|
|
|
@@ -1003,6 +1018,7 @@ export function ImageWorkbenchPage({
|
|
|
|
|
// 切换对话:置为 active 并回填它的批次
|
|
|
|
|
const selectConversation = useCallback((convId: string) => {
|
|
|
|
|
if (convId === activeConvRef.current) return;
|
|
|
|
|
activeConvRef.current = convId;
|
|
|
|
|
setActiveConvId(convId);
|
|
|
|
|
setRenamingId("");
|
|
|
|
|
void loadConvBatches(convId);
|
|
|
|
@@ -1011,9 +1027,10 @@ export function ImageWorkbenchPage({
|
|
|
|
|
// 新对话:后端建一条 → 置顶列表 → 设为 active → 清空批次流
|
|
|
|
|
async function handleNewConversation() {
|
|
|
|
|
try {
|
|
|
|
|
const conv = await api.createConversation({ mode, product: product?.id || null });
|
|
|
|
|
const conv = await api.createConversation({ mode, product: imageProductId || null });
|
|
|
|
|
setConvError("");
|
|
|
|
|
setConversations((prev) => [conv, ...prev]);
|
|
|
|
|
activeConvRef.current = conv.id;
|
|
|
|
|
setActiveConvId(conv.id);
|
|
|
|
|
setBatches([]);
|
|
|
|
|
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
|
|
|
|
@@ -1039,6 +1056,7 @@ export function ImageWorkbenchPage({
|
|
|
|
|
setConversations(remaining);
|
|
|
|
|
if (convId === activeConvId) {
|
|
|
|
|
const nextActive = remaining[0]?.id || "";
|
|
|
|
|
activeConvRef.current = nextActive;
|
|
|
|
|
setActiveConvId(nextActive);
|
|
|
|
|
if (nextActive) void loadConvBatches(nextActive);
|
|
|
|
|
else setBatches([]);
|
|
|
|
@@ -1050,24 +1068,40 @@ export function ImageWorkbenchPage({
|
|
|
|
|
// autoSelect=true(首次进入):自动选最近一条并回填历史;false(首发后只刷新列表):不动当前对话/批次。
|
|
|
|
|
const loadConversations = useCallback(async (autoSelect = true) => {
|
|
|
|
|
if (mode !== "image") return;
|
|
|
|
|
const loadSeq = ++conversationLoadSeqRef.current;
|
|
|
|
|
setConvLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await api.listConversations(mode);
|
|
|
|
|
const res = await api.listConversations(mode, imageProductId);
|
|
|
|
|
if (loadSeq !== conversationLoadSeqRef.current) return;
|
|
|
|
|
setConversations(res.results);
|
|
|
|
|
if (autoSelect && res.results.length && !activeConvRef.current) {
|
|
|
|
|
activeConvRef.current = res.results[0].id;
|
|
|
|
|
setActiveConvId(res.results[0].id);
|
|
|
|
|
void loadConvBatches(res.results[0].id);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
if (loadSeq !== conversationLoadSeqRef.current) return;
|
|
|
|
|
setConversations([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setConvLoading(false);
|
|
|
|
|
if (loadSeq === conversationLoadSeqRef.current) setConvLoading(false);
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [mode, loadConvBatches]);
|
|
|
|
|
}, [mode, imageProductId, loadConvBatches]);
|
|
|
|
|
|
|
|
|
|
// 只在挂载 / 切 mode 时加载一次对话列表(不依赖 callback 身份,否则 onResume 每次渲染变更会触发反复重拉)
|
|
|
|
|
useEffect(() => { void loadConversations(); }, [mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
// 图片创作范围变化时先同步清掉旧活动会话,再加载当前商品/通用范围;序号防止旧请求晚到覆盖新列表。
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (mode !== "image") return;
|
|
|
|
|
conversationLoadSeqRef.current += 1;
|
|
|
|
|
conversationBatchLoadSeqRef.current += 1;
|
|
|
|
|
activeConvRef.current = "";
|
|
|
|
|
setActiveConvId("");
|
|
|
|
|
setConversations([]);
|
|
|
|
|
setBatches([]);
|
|
|
|
|
setRenamingId("");
|
|
|
|
|
setConvError("");
|
|
|
|
|
void loadConversations();
|
|
|
|
|
return () => { conversationLoadSeqRef.current += 1; };
|
|
|
|
|
}, [mode, conversationScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
|
|
|
|
|
async function startBatch(opts: {
|
|
|
|
@@ -1092,6 +1126,7 @@ export function ImageWorkbenchPage({
|
|
|
|
|
/** 单格重跑要替代的原失败任务。 */
|
|
|
|
|
retryOfTaskId?: string;
|
|
|
|
|
}) {
|
|
|
|
|
const submittedScopeKey = conversationScopeKey;
|
|
|
|
|
// 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片
|
|
|
|
|
const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
|
if (opts.reuseBatchId) {
|
|
|
|
@@ -1146,6 +1181,7 @@ export function ImageWorkbenchPage({
|
|
|
|
|
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
|
|
|
|
|
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, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId,
|
|
|
|
|
onSubmitted: (taskIds, submittedBatchId) => {
|
|
|
|
|
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
|
|
|
|
setBatches((prev) => prev.map((b) => {
|
|
|
|
|
if (b.id !== batchId) return b;
|
|
|
|
|
const allTaskIds = opts.appendToBatchId ? [...new Set([...(b.taskIds || []), ...taskIds])] : taskIds;
|
|
|
|
@@ -1154,9 +1190,11 @@ export function ImageWorkbenchPage({
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
|
|
|
|
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
|
|
|
|
|
const convId = result?.conversation_id;
|
|
|
|
|
if (convId && convId !== activeConvRef.current) {
|
|
|
|
|
activeConvRef.current = convId;
|
|
|
|
|
setActiveConvId(convId);
|
|
|
|
|
void loadConversations(false);
|
|
|
|
|
}
|
|
|
|
@@ -1185,6 +1223,7 @@ export function ImageWorkbenchPage({
|
|
|
|
|
return { ...b, backendBatchId, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined };
|
|
|
|
|
}));
|
|
|
|
|
} catch {
|
|
|
|
|
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
|
|
|
|
// 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed)
|
|
|
|
|
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b)));
|
|
|
|
|
}
|
|
|
|
@@ -1192,14 +1231,17 @@ export function ImageWorkbenchPage({
|
|
|
|
|
|
|
|
|
|
async function runGenerate() {
|
|
|
|
|
if (!canGenerate) return;
|
|
|
|
|
const requestedScopeKey = conversationScopeKey;
|
|
|
|
|
// 图片创作:生成前先把对话「坐实」到侧栏(若还没有),这样这条长达 ~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]);
|
|
|
|
|
const conv = await api.createConversation({ mode, product: imageProductId || null, title: prompt.trim().slice(0, 24) || undefined });
|
|
|
|
|
if (requestedScopeKey === conversationScopeKeyRef.current) {
|
|
|
|
|
activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到
|
|
|
|
|
setActiveConvId(conv.id);
|
|
|
|
|
setConversations((prev) => [conv, ...prev]);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* 建会话失败:回退到后端自动建(仍能生成,只是生成中暂不可切回) */
|
|
|
|
|
}
|
|
|
|
@@ -1208,8 +1250,8 @@ export function ImageWorkbenchPage({
|
|
|
|
|
prompt: prompt.trim(),
|
|
|
|
|
ratio,
|
|
|
|
|
count: candidateCount,
|
|
|
|
|
productId: product?.id,
|
|
|
|
|
productTitle: product?.title
|
|
|
|
|
productId: mode === "image" ? imageProductId : product?.id,
|
|
|
|
|
productTitle: mode === "image" ? imageProduct?.title : product?.title
|
|
|
|
|
};
|
|
|
|
|
if (mode === "cover") {
|
|
|
|
|
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
|
|
|
|
@@ -1240,8 +1282,8 @@ export function ImageWorkbenchPage({
|
|
|
|
|
ratio: src.ratio,
|
|
|
|
|
count: src.count,
|
|
|
|
|
// 重跑归属原批次的商品,而非当前选中商品
|
|
|
|
|
productId: src.productId ?? product?.id,
|
|
|
|
|
productTitle: src.productTitle ?? product?.title,
|
|
|
|
|
productId: mode === "image" ? src.productId : (src.productId ?? product?.id),
|
|
|
|
|
productTitle: mode === "image" ? src.productTitle : (src.productTitle ?? product?.title),
|
|
|
|
|
modelId: src.modelId,
|
|
|
|
|
modelName: src.modelName,
|
|
|
|
|
platformIds: src.platformIds,
|
|
|
|
@@ -1317,8 +1359,8 @@ export function ImageWorkbenchPage({
|
|
|
|
|
prompt: src.prompt,
|
|
|
|
|
ratio: src.ratio,
|
|
|
|
|
count: 1,
|
|
|
|
|
productId: src.productId ?? product?.id,
|
|
|
|
|
productTitle: src.productTitle ?? product?.title,
|
|
|
|
|
productId: mode === "image" ? src.productId : (src.productId ?? product?.id),
|
|
|
|
|
productTitle: mode === "image" ? src.productTitle : (src.productTitle ?? product?.title),
|
|
|
|
|
modelId: src.modelId,
|
|
|
|
|
modelName: src.modelName,
|
|
|
|
|
platformIds: src.platformIds,
|
|
|
|
@@ -1591,7 +1633,7 @@ export function ImageWorkbenchPage({
|
|
|
|
|
新对话
|
|
|
|
|
</button>
|
|
|
|
|
{convError && <div className="ic-conv-error">{convError}</div>}
|
|
|
|
|
<div className="ic-side-sec">最近</div>
|
|
|
|
|
<div className="ic-side-sec">{conversationScopeLabel}</div>
|
|
|
|
|
<div className="ic-conv-list">
|
|
|
|
|
{conversations.length === 0 ? (
|
|
|
|
|
<div className="ic-conv-empty">
|
|
|
|
|