xiaolongmr commited on
Commit ·
1b90ec4
1
Parent(s): 48b1e94
撤回批量生图功能(48b1e94a)
Browse files用户决定暂不加该功能,代码回到 48b1e94a 之前的状态:
- image-composer.tsx / page.tsx / prompt-composer.tsx 完全还原,
不再有「批量」入口、多行编辑器与 handleBatchSubmit
- 保留 docs/批量生图-方案.md(设计文档,不影响构建与运行)
web/src/app/image/components/image-composer.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import { Check, ChevronDown, ImagePlus,
|
| 3 |
-
import {
|
| 4 |
|
| 5 |
import {
|
| 6 |
PromptComposerResizeHandle,
|
|
@@ -78,11 +78,6 @@ type ImageComposerProps = {
|
|
| 78 |
onMemoryToggle: () => void;
|
| 79 |
onAccountPoolStrategyChange: (value: string) => void;
|
| 80 |
onSubmit: () => void | Promise<void>;
|
| 81 |
-
/**
|
| 82 |
-
* 批量生图提交。每一行会被展开成一个独立的生成任务(复用现有单张链路),
|
| 83 |
-
* 返回 false 表示校验未通过(积分/额度不足等),此时输入卡不清空。
|
| 84 |
-
*/
|
| 85 |
-
onBatchSubmit: (rows: BatchRowInput[]) => boolean | Promise<boolean>;
|
| 86 |
onOptimizePrompt: () => void | Promise<void>;
|
| 87 |
isOptimizingPrompt?: boolean;
|
| 88 |
onBenefitsClick?: () => void;
|
|
@@ -96,50 +91,6 @@ type ImageComposerProps = {
|
|
| 96 |
|
| 97 |
const imageFileNamePattern = /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i;
|
| 98 |
|
| 99 |
-
/** 批量生图:单行的提交参数。 */
|
| 100 |
-
export type BatchRowInput = {
|
| 101 |
-
/** 该行的提示词(已剥离 `@@N` 后缀) */
|
| 102 |
-
prompt: string;
|
| 103 |
-
/** 该行要生成的张数 */
|
| 104 |
-
count: number;
|
| 105 |
-
};
|
| 106 |
-
|
| 107 |
-
type BatchEditorRow = {
|
| 108 |
-
id: string;
|
| 109 |
-
text: string;
|
| 110 |
-
count: number;
|
| 111 |
-
};
|
| 112 |
-
|
| 113 |
-
const MAX_BATCH_ROWS = 50;
|
| 114 |
-
const MAX_BATCH_COUNT = 100;
|
| 115 |
-
|
| 116 |
-
function createBatchRowId() {
|
| 117 |
-
return `batch-row-${Math.random().toString(36).slice(2, 10)}`;
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
function createBatchRow(count = 1): BatchEditorRow {
|
| 121 |
-
return { id: createBatchRowId(), text: "", count };
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
/**
|
| 125 |
-
* 解析批量编辑器的一行:
|
| 126 |
-
* - 空行 / `#` 开头 → null(忽略)
|
| 127 |
-
* - 行尾 `@@3` → 覆盖该行张数
|
| 128 |
-
*/
|
| 129 |
-
function parseBatchLine(text: string, fallbackCount: number): BatchRowInput | null {
|
| 130 |
-
const raw = text.trim();
|
| 131 |
-
if (!raw || raw.startsWith("#")) return null;
|
| 132 |
-
const normalizedFallback = Math.max(1, Math.min(MAX_BATCH_COUNT, Math.floor(fallbackCount) || 1));
|
| 133 |
-
const match = raw.match(/@@\s*(\d{1,3})\s*$/);
|
| 134 |
-
if (!match) {
|
| 135 |
-
return { prompt: raw, count: normalizedFallback };
|
| 136 |
-
}
|
| 137 |
-
const prompt = raw.replace(/@@\s*\d{1,3}\s*$/, "").trim();
|
| 138 |
-
if (!prompt) return null;
|
| 139 |
-
const count = Math.max(1, Math.min(MAX_BATCH_COUNT, Number(match[1]) || normalizedFallback));
|
| 140 |
-
return { prompt, count };
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
const defaultImageSizeOptions = [
|
| 144 |
{ value: "", label: "智能比例" },
|
| 145 |
{ value: "1:1", label: "1:1(正方形)" },
|
|
@@ -205,7 +156,6 @@ export function ImageComposer({
|
|
| 205 |
onMemoryToggle,
|
| 206 |
onAccountPoolStrategyChange,
|
| 207 |
onSubmit,
|
| 208 |
-
onBatchSubmit,
|
| 209 |
onOptimizePrompt,
|
| 210 |
isOptimizingPrompt = false,
|
| 211 |
onBenefitsClick,
|
|
@@ -222,11 +172,6 @@ export function ImageComposer({
|
|
| 222 |
const [customRatioHeight, setCustomRatioHeight] = useState("1");
|
| 223 |
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
| 224 |
const [isDraggingImage, setIsDraggingImage] = useState(false);
|
| 225 |
-
const [isBatchMode, setIsBatchMode] = useState(false);
|
| 226 |
-
const [isSubmittingBatch, setIsSubmittingBatch] = useState(false);
|
| 227 |
-
const [batchRows, setBatchRows] = useState<BatchEditorRow[]>(() => [createBatchRow()]);
|
| 228 |
-
const batchRowInputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
| 229 |
-
const pendingBatchFocusIdRef = useRef<string | null>(null);
|
| 230 |
const [sizeMenuPos, setSizeMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
| 231 |
const [modelMenuPos, setModelMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
| 232 |
// 心跳由全局 PresenceProvider 统一上报(覆盖站内所有页面),这里只负责展示数字。
|
|
@@ -310,18 +255,6 @@ export function ImageComposer({
|
|
| 310 |
const unitCreditCostLabel = Number.isInteger(unitCreditCost)
|
| 311 |
? String(unitCreditCost)
|
| 312 |
: unitCreditCost.toFixed(2).replace(/\.?0+$/, "");
|
| 313 |
-
|
| 314 |
-
const batchParsedRows = useMemo(
|
| 315 |
-
() => batchRows.map((row) => ({ row, parsed: parseBatchLine(row.text, row.count) })),
|
| 316 |
-
[batchRows],
|
| 317 |
-
);
|
| 318 |
-
const batchValidRowCount = batchParsedRows.filter((item) => item.parsed).length;
|
| 319 |
-
const batchTotalCount = batchParsedRows.reduce((sum, item) => sum + (item.parsed?.count ?? 0), 0);
|
| 320 |
-
const batchIgnoredRowCount = batchParsedRows.filter((item) => !item.parsed && item.row.text.trim()).length;
|
| 321 |
-
const batchEstimatedCreditCost = Math.round(unitCreditCost * batchTotalCount * 100) / 100;
|
| 322 |
-
const batchEstimatedCreditCostLabel = Number.isInteger(batchEstimatedCreditCost)
|
| 323 |
-
? String(batchEstimatedCreditCost)
|
| 324 |
-
: batchEstimatedCreditCost.toFixed(2).replace(/\.?0+$/, "");
|
| 325 |
const requiresImageSize = imageModel.trim().toLowerCase() === "gpt-image-2-4k";
|
| 326 |
const selectedImageSizeOption = imageSizeOptions.find((option) => option.value === imageSize);
|
| 327 |
const isCustomImageSize = Boolean(imageSize && !selectedImageSizeOption && isSupportedAspectRatio(imageSize));
|
|
@@ -367,117 +300,6 @@ export function ImageComposer({
|
|
| 367 |
);
|
| 368 |
const accountPoolLabel = selectableAccountPoolOptions[accountPoolIndex]?.label || selectableAccountPoolOptions[0].label;
|
| 369 |
|
| 370 |
-
useEffect(() => {
|
| 371 |
-
// 批量行文本框按内容自适应高度(最多 96px),粘贴多行后也能立刻撑开。
|
| 372 |
-
batchRowInputRefs.current.forEach((el) => {
|
| 373 |
-
if (!el) return;
|
| 374 |
-
el.style.height = "auto";
|
| 375 |
-
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
|
| 376 |
-
});
|
| 377 |
-
const pendingId = pendingBatchFocusIdRef.current;
|
| 378 |
-
if (!pendingId) return;
|
| 379 |
-
pendingBatchFocusIdRef.current = null;
|
| 380 |
-
const target = batchRowInputRefs.current.get(pendingId);
|
| 381 |
-
if (!target) return;
|
| 382 |
-
target.focus();
|
| 383 |
-
const end = target.value.length;
|
| 384 |
-
target.setSelectionRange(end, end);
|
| 385 |
-
}, [batchRows]);
|
| 386 |
-
|
| 387 |
-
const enterBatchMode = useCallback(() => {
|
| 388 |
-
setBatchRows((rows) => {
|
| 389 |
-
const trimmed = prompt.trim();
|
| 390 |
-
// 切到批量时把已经敲在单张输入框里的提示词带进来,避免内容凭空消失。
|
| 391 |
-
if (trimmed && rows.length === 1 && !rows[0].text.trim()) {
|
| 392 |
-
return [{ ...rows[0], text: trimmed }];
|
| 393 |
-
}
|
| 394 |
-
return rows;
|
| 395 |
-
});
|
| 396 |
-
setIsBatchMode(true);
|
| 397 |
-
}, [prompt]);
|
| 398 |
-
|
| 399 |
-
const exitBatchMode = useCallback(() => {
|
| 400 |
-
setIsBatchMode(false);
|
| 401 |
-
}, []);
|
| 402 |
-
|
| 403 |
-
const updateBatchRow = useCallback((id: string, patch: Partial<BatchEditorRow>) => {
|
| 404 |
-
setBatchRows((rows) => rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
| 405 |
-
}, []);
|
| 406 |
-
|
| 407 |
-
const addBatchRow = useCallback((afterId?: string) => {
|
| 408 |
-
const created = createBatchRow();
|
| 409 |
-
setBatchRows((rows) => {
|
| 410 |
-
if (rows.length >= MAX_BATCH_ROWS) return rows;
|
| 411 |
-
if (!afterId) return [...rows, created];
|
| 412 |
-
const index = rows.findIndex((row) => row.id === afterId);
|
| 413 |
-
if (index < 0) return [...rows, created];
|
| 414 |
-
const next = [...rows];
|
| 415 |
-
next.splice(index + 1, 0, created);
|
| 416 |
-
return next;
|
| 417 |
-
});
|
| 418 |
-
pendingBatchFocusIdRef.current = created.id;
|
| 419 |
-
}, []);
|
| 420 |
-
|
| 421 |
-
const removeBatchRow = useCallback((id: string) => {
|
| 422 |
-
setBatchRows((rows) => {
|
| 423 |
-
if (rows.length <= 1) {
|
| 424 |
-
return rows.map((row) => (row.id === id ? { ...row, text: "", count: 1 } : row));
|
| 425 |
-
}
|
| 426 |
-
return rows.filter((row) => row.id !== id);
|
| 427 |
-
});
|
| 428 |
-
}, []);
|
| 429 |
-
|
| 430 |
-
const handleBatchRowKeyDown = useCallback(
|
| 431 |
-
(event: KeyboardEvent<HTMLTextAreaElement>, id: string) => {
|
| 432 |
-
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
|
| 433 |
-
event.preventDefault();
|
| 434 |
-
addBatchRow(id);
|
| 435 |
-
},
|
| 436 |
-
[addBatchRow],
|
| 437 |
-
);
|
| 438 |
-
|
| 439 |
-
const handleBatchRowPaste = useCallback((event: ClipboardEvent<HTMLTextAreaElement>, id: string) => {
|
| 440 |
-
const text = event.clipboardData?.getData("text/plain") ?? "";
|
| 441 |
-
if (!text || !/[\r\n]/.test(text)) return;
|
| 442 |
-
const lines = text
|
| 443 |
-
.split(/\r?\n/)
|
| 444 |
-
.map((line) => line.trim())
|
| 445 |
-
.filter(Boolean);
|
| 446 |
-
if (lines.length <= 1) return;
|
| 447 |
-
event.preventDefault();
|
| 448 |
-
setBatchRows((rows) => {
|
| 449 |
-
const index = rows.findIndex((row) => row.id === id);
|
| 450 |
-
if (index < 0) return rows;
|
| 451 |
-
const head = rows[index];
|
| 452 |
-
const next = [...rows];
|
| 453 |
-
const [firstLine, ...restLines] = lines;
|
| 454 |
-
next[index] = { ...head, text: firstLine };
|
| 455 |
-
next.splice(
|
| 456 |
-
index + 1,
|
| 457 |
-
0,
|
| 458 |
-
...restLines.map((line) => ({ ...createBatchRow(head.count), text: line })),
|
| 459 |
-
);
|
| 460 |
-
return next;
|
| 461 |
-
});
|
| 462 |
-
}, []);
|
| 463 |
-
|
| 464 |
-
const handleBatchSubmitClick = useCallback(async () => {
|
| 465 |
-
const rows = batchParsedRows
|
| 466 |
-
.map((item) => item.parsed)
|
| 467 |
-
.filter((item): item is BatchRowInput => Boolean(item));
|
| 468 |
-
if (rows.length === 0 || isSubmittingBatch) return;
|
| 469 |
-
setIsSubmittingBatch(true);
|
| 470 |
-
try {
|
| 471 |
-
const accepted = await onBatchSubmit(rows);
|
| 472 |
-
if (accepted) {
|
| 473 |
-
setBatchRows([createBatchRow()]);
|
| 474 |
-
setIsBatchMode(false);
|
| 475 |
-
}
|
| 476 |
-
} finally {
|
| 477 |
-
setIsSubmittingBatch(false);
|
| 478 |
-
}
|
| 479 |
-
}, [batchParsedRows, isSubmittingBatch, onBatchSubmit]);
|
| 480 |
-
|
| 481 |
useEffect(() => {
|
| 482 |
const controls = controlsScrollRef.current;
|
| 483 |
if (!controls) return;
|
|
@@ -686,123 +508,14 @@ export function ImageComposer({
|
|
| 686 |
onDragLeave={handleComposerDragLeave}
|
| 687 |
onDrop={handleComposerDrop}
|
| 688 |
onClick={() => {
|
| 689 |
-
if (isBatchMode) return;
|
| 690 |
textareaRef.current?.focus();
|
| 691 |
}}
|
| 692 |
>
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
)}
|
| 699 |
-
{isBatchMode ? (
|
| 700 |
-
<div className="px-4 pb-1.5 pt-3 sm:px-5">
|
| 701 |
-
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
| 702 |
-
<span className="text-xs font-medium text-stone-800">批量生成</span>
|
| 703 |
-
<span className="text-[11px] leading-4 text-stone-400">
|
| 704 |
-
每行一个提示词 · 行尾 @@3 指定张数 · # 开头为注释
|
| 705 |
-
</span>
|
| 706 |
-
</div>
|
| 707 |
-
<div className="max-h-[44dvh] space-y-1.5 overflow-y-auto pr-0.5">
|
| 708 |
-
{batchParsedRows.map(({ row, parsed }, index) => {
|
| 709 |
-
const isIgnored = !parsed && row.text.trim().length > 0;
|
| 710 |
-
const effectiveCount = parsed?.count ?? row.count;
|
| 711 |
-
return (
|
| 712 |
-
<div key={row.id} className="flex items-start gap-1.5">
|
| 713 |
-
<span className="mt-2 w-4 shrink-0 text-right text-[11px] tabular-nums text-stone-400">
|
| 714 |
-
{index + 1}
|
| 715 |
-
</span>
|
| 716 |
-
<textarea
|
| 717 |
-
ref={(node) => {
|
| 718 |
-
if (node) batchRowInputRefs.current.set(row.id, node);
|
| 719 |
-
else batchRowInputRefs.current.delete(row.id);
|
| 720 |
-
}}
|
| 721 |
-
value={row.text}
|
| 722 |
-
rows={1}
|
| 723 |
-
spellCheck={false}
|
| 724 |
-
data-batch-row={row.id}
|
| 725 |
-
data-batch-row-index={index}
|
| 726 |
-
onChange={(event) => {
|
| 727 |
-
updateBatchRow(row.id, { text: event.target.value });
|
| 728 |
-
const el = event.currentTarget;
|
| 729 |
-
el.style.height = "auto";
|
| 730 |
-
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
|
| 731 |
-
}}
|
| 732 |
-
onKeyDown={(event) => handleBatchRowKeyDown(event, row.id)}
|
| 733 |
-
onPaste={(event) => handleBatchRowPaste(event, row.id)}
|
| 734 |
-
placeholder={index === 0 ? "第一行提示词…" : ""}
|
| 735 |
-
className={cn(
|
| 736 |
-
"min-h-9 flex-1 resize-none rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm leading-5 text-stone-800 shadow-none outline-none transition placeholder:text-stone-400 focus-visible:border-stone-400 focus-visible:ring-0",
|
| 737 |
-
isIgnored && "text-stone-400",
|
| 738 |
-
)}
|
| 739 |
-
/>
|
| 740 |
-
<span className="mt-1 flex h-9 shrink-0 items-center gap-0.5 rounded-full border border-stone-200 bg-white px-0.5 text-xs text-stone-600">
|
| 741 |
-
<button
|
| 742 |
-
type="button"
|
| 743 |
-
onClick={() =>
|
| 744 |
-
updateBatchRow(row.id, {
|
| 745 |
-
text: row.text.replace(/@@\s*\d{1,3}\s*$/, "").trim(),
|
| 746 |
-
count: Math.max(1, effectiveCount - 1),
|
| 747 |
-
})
|
| 748 |
-
}
|
| 749 |
-
disabled={effectiveCount <= 1}
|
| 750 |
-
className="flex size-6 items-center justify-center rounded-full text-stone-500 transition hover:bg-stone-100 hover:text-stone-800 disabled:opacity-40"
|
| 751 |
-
aria-label="减少张数"
|
| 752 |
-
>
|
| 753 |
-
<Minus className="size-3" />
|
| 754 |
-
</button>
|
| 755 |
-
<span className="w-6 text-center tabular-nums">{effectiveCount}</span>
|
| 756 |
-
<button
|
| 757 |
-
type="button"
|
| 758 |
-
onClick={() =>
|
| 759 |
-
updateBatchRow(row.id, {
|
| 760 |
-
text: row.text.replace(/@@\s*\d{1,3}\s*$/, "").trim(),
|
| 761 |
-
count: Math.min(MAX_BATCH_COUNT, effectiveCount + 1),
|
| 762 |
-
})
|
| 763 |
-
}
|
| 764 |
-
disabled={effectiveCount >= MAX_BATCH_COUNT}
|
| 765 |
-
className="flex size-6 items-center justify-center rounded-full text-stone-500 transition hover:bg-stone-100 hover:text-stone-800 disabled:opacity-40"
|
| 766 |
-
aria-label="增加张数"
|
| 767 |
-
>
|
| 768 |
-
<Plus className="size-3" />
|
| 769 |
-
</button>
|
| 770 |
-
</span>
|
| 771 |
-
<span
|
| 772 |
-
aria-hidden
|
| 773 |
-
className={cn(
|
| 774 |
-
"mt-4 size-1.5 shrink-0 rounded-full",
|
| 775 |
-
parsed ? "bg-emerald-500" : isIgnored ? "bg-stone-300" : "bg-transparent",
|
| 776 |
-
)}
|
| 777 |
-
/>
|
| 778 |
-
<button
|
| 779 |
-
type="button"
|
| 780 |
-
onClick={() => removeBatchRow(row.id)}
|
| 781 |
-
className="mt-1 flex size-9 shrink-0 items-center justify-center rounded-full text-stone-400 transition hover:bg-stone-100 hover:text-stone-700"
|
| 782 |
-
aria-label="删除该行"
|
| 783 |
-
title="删除该行"
|
| 784 |
-
>
|
| 785 |
-
<Trash2 className="size-3.5" />
|
| 786 |
-
</button>
|
| 787 |
-
</div>
|
| 788 |
-
);
|
| 789 |
-
})}
|
| 790 |
-
</div>
|
| 791 |
-
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
| 792 |
-
<button
|
| 793 |
-
type="button"
|
| 794 |
-
onClick={() => addBatchRow()}
|
| 795 |
-
data-testid="batch-add-row"
|
| 796 |
-
className="inline-flex items-center gap-1 rounded-full border border-dashed border-stone-300 px-2.5 py-1 text-[11px] font-medium text-stone-600 transition hover:border-stone-400 hover:text-stone-800"
|
| 797 |
-
>
|
| 798 |
-
<Plus className="size-3" />
|
| 799 |
-
添加一行
|
| 800 |
-
</button>
|
| 801 |
-
<span className="text-[11px] text-stone-400">回车新增下一行 · 粘贴多行文本自动拆分</span>
|
| 802 |
-
</div>
|
| 803 |
-
</div>
|
| 804 |
-
) : (
|
| 805 |
-
<PromptComposerTextarea
|
| 806 |
ref={textareaRef}
|
| 807 |
value={prompt}
|
| 808 |
style={promptHeight ? { height: `${promptHeight}px` } : undefined}
|
|
@@ -817,7 +530,6 @@ export function ImageComposer({
|
|
| 817 |
onOptimize={onOptimizePrompt}
|
| 818 |
isOptimizing={isOptimizingPrompt}
|
| 819 |
/>
|
| 820 |
-
)}
|
| 821 |
{isDraggingImage ? (
|
| 822 |
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[24px] border-2 border-dashed border-stone-900 bg-white/85 text-sm font-medium text-stone-900 backdrop-blur-[1px] sm:rounded-[32px]">
|
| 823 |
<div className="flex items-center gap-2 rounded-full bg-stone-950 px-4 py-2 text-white shadow-lg">
|
|
@@ -842,23 +554,6 @@ export function ImageComposer({
|
|
| 842 |
>
|
| 843 |
<ImagePlus className="size-3.5 sm:size-4" />
|
| 844 |
</Button>
|
| 845 |
-
<Button
|
| 846 |
-
type="button"
|
| 847 |
-
variant="outline"
|
| 848 |
-
className={cn(
|
| 849 |
-
"h-9 shrink-0 gap-1 rounded-full px-2.5 text-[11px] font-medium shadow-none sm:h-10 sm:gap-1.5 sm:px-3 sm:text-xs",
|
| 850 |
-
isBatchMode
|
| 851 |
-
? "border-stone-900 bg-stone-950 text-white hover:bg-stone-800 hover:text-white active:text-white"
|
| 852 |
-
: "border-stone-200 bg-white text-stone-700 hover:bg-stone-100",
|
| 853 |
-
)}
|
| 854 |
-
onClick={() => (isBatchMode ? exitBatchMode() : enterBatchMode())}
|
| 855 |
-
data-testid="batch-toggle"
|
| 856 |
-
aria-label={isBatchMode ? "退出批量模式" : "批量生图"}
|
| 857 |
-
title={isBatchMode ? "退出批量模式" : "批量生图(一行一个提示词)"}
|
| 858 |
-
>
|
| 859 |
-
{isBatchMode ? <X className="size-3.5" /> : <List className="size-3.5" />}
|
| 860 |
-
<span className="hidden sm:inline">批量</span>
|
| 861 |
-
</Button>
|
| 862 |
{availableQuota !== "--" && (
|
| 863 |
onBenefitsClick ? (
|
| 864 |
<Button
|
|
@@ -1046,9 +741,7 @@ export function ImageComposer({
|
|
| 1046 |
</div>
|
| 1047 |
)}
|
| 1048 |
<div className="flex h-9 shrink-0 items-center gap-1 rounded-full border border-stone-200 bg-white px-2 py-0.5 sm:h-auto sm:px-2.5 sm:py-1">
|
| 1049 |
-
<span className="hidden text-[11px] font-medium text-stone-700 sm:inline sm:text-sm">
|
| 1050 |
-
{isBatchMode ? "每行张数" : "张数"}
|
| 1051 |
-
</span>
|
| 1052 |
<Input
|
| 1053 |
type="number"
|
| 1054 |
inputMode="numeric"
|
|
@@ -1212,31 +905,6 @@ export function ImageComposer({
|
|
| 1212 |
</>
|
| 1213 |
}
|
| 1214 |
actions={
|
| 1215 |
-
isBatchMode ? (
|
| 1216 |
-
<>
|
| 1217 |
-
<output
|
| 1218 |
-
data-testid="batch-estimated-credit-cost"
|
| 1219 |
-
className="text-right font-normal text-stone-400 tabular-nums min-w-[7.5rem] text-[11px] leading-tight sm:text-xs"
|
| 1220 |
-
aria-label={`批量:${batchValidRowCount} 行共 ${batchTotalCount} 张,单张 ${unitCreditCostLabel} 积分,预计扣除 ${batchEstimatedCreditCostLabel} 积分`}
|
| 1221 |
-
title={`批量 ${batchValidRowCount} 行 × ${batchTotalCount} 张,单张 ${unitCreditCostLabel} 积分,预计扣除 ${batchEstimatedCreditCostLabel} 积分`}
|
| 1222 |
-
>
|
| 1223 |
-
<span className="flex flex-col items-end">
|
| 1224 |
-
<span className="font-normal text-stone-500">预计 {batchEstimatedCreditCostLabel} 积分</span>
|
| 1225 |
-
<span className="text-[10px] text-stone-400">
|
| 1226 |
-
{batchValidRowCount} 行 · 共 {batchTotalCount} 张
|
| 1227 |
-
{batchIgnoredRowCount > 0 ? ` · 忽略 ${batchIgnoredRowCount} 行` : ""}
|
| 1228 |
-
</span>
|
| 1229 |
-
</span>
|
| 1230 |
-
</output>
|
| 1231 |
-
<PromptComposerSubmitButton
|
| 1232 |
-
onSubmit={handleBatchSubmitClick}
|
| 1233 |
-
disabled={batchValidRowCount === 0 || isSubmittingBatch}
|
| 1234 |
-
label={`开始生成 ${batchTotalCount} 张`}
|
| 1235 |
-
title={`开始生成 ${batchTotalCount} 张(共 ${batchValidRowCount} 行)`}
|
| 1236 |
-
testId="batch-submit"
|
| 1237 |
-
/>
|
| 1238 |
-
</>
|
| 1239 |
-
) : (
|
| 1240 |
<>
|
| 1241 |
<output
|
| 1242 |
data-testid="estimated-credit-cost"
|
|
@@ -1260,7 +928,6 @@ export function ImageComposer({
|
|
| 1260 |
label={referenceImages.length > 0 ? "编辑图片" : "生成图片"}
|
| 1261 |
/>
|
| 1262 |
</>
|
| 1263 |
-
)
|
| 1264 |
}
|
| 1265 |
/>
|
| 1266 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { Check, ChevronDown, ImagePlus, LoaderCircle, X } from "lucide-react";
|
| 3 |
+
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type PointerEvent, type RefObject } from "react";
|
| 4 |
|
| 5 |
import {
|
| 6 |
PromptComposerResizeHandle,
|
|
|
|
| 78 |
onMemoryToggle: () => void;
|
| 79 |
onAccountPoolStrategyChange: (value: string) => void;
|
| 80 |
onSubmit: () => void | Promise<void>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
onOptimizePrompt: () => void | Promise<void>;
|
| 82 |
isOptimizingPrompt?: boolean;
|
| 83 |
onBenefitsClick?: () => void;
|
|
|
|
| 91 |
|
| 92 |
const imageFileNamePattern = /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i;
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
const defaultImageSizeOptions = [
|
| 95 |
{ value: "", label: "智能比例" },
|
| 96 |
{ value: "1:1", label: "1:1(正方形)" },
|
|
|
|
| 156 |
onMemoryToggle,
|
| 157 |
onAccountPoolStrategyChange,
|
| 158 |
onSubmit,
|
|
|
|
| 159 |
onOptimizePrompt,
|
| 160 |
isOptimizingPrompt = false,
|
| 161 |
onBenefitsClick,
|
|
|
|
| 172 |
const [customRatioHeight, setCustomRatioHeight] = useState("1");
|
| 173 |
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
| 174 |
const [isDraggingImage, setIsDraggingImage] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
const [sizeMenuPos, setSizeMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
| 176 |
const [modelMenuPos, setModelMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
| 177 |
// 心跳由全局 PresenceProvider 统一上报(覆盖站内所有页面),这里只负责展示数字。
|
|
|
|
| 255 |
const unitCreditCostLabel = Number.isInteger(unitCreditCost)
|
| 256 |
? String(unitCreditCost)
|
| 257 |
: unitCreditCost.toFixed(2).replace(/\.?0+$/, "");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
const requiresImageSize = imageModel.trim().toLowerCase() === "gpt-image-2-4k";
|
| 259 |
const selectedImageSizeOption = imageSizeOptions.find((option) => option.value === imageSize);
|
| 260 |
const isCustomImageSize = Boolean(imageSize && !selectedImageSizeOption && isSupportedAspectRatio(imageSize));
|
|
|
|
| 300 |
);
|
| 301 |
const accountPoolLabel = selectableAccountPoolOptions[accountPoolIndex]?.label || selectableAccountPoolOptions[0].label;
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
useEffect(() => {
|
| 304 |
const controls = controlsScrollRef.current;
|
| 305 |
if (!controls) return;
|
|
|
|
| 508 |
onDragLeave={handleComposerDragLeave}
|
| 509 |
onDrop={handleComposerDrop}
|
| 510 |
onClick={() => {
|
|
|
|
| 511 |
textareaRef.current?.focus();
|
| 512 |
}}
|
| 513 |
>
|
| 514 |
+
<PromptComposerResizeHandle
|
| 515 |
+
onClick={(event) => event.stopPropagation()}
|
| 516 |
+
onPointerDown={handlePromptResizePointerDown}
|
| 517 |
+
/>
|
| 518 |
+
<PromptComposerTextarea
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
ref={textareaRef}
|
| 520 |
value={prompt}
|
| 521 |
style={promptHeight ? { height: `${promptHeight}px` } : undefined}
|
|
|
|
| 530 |
onOptimize={onOptimizePrompt}
|
| 531 |
isOptimizing={isOptimizingPrompt}
|
| 532 |
/>
|
|
|
|
| 533 |
{isDraggingImage ? (
|
| 534 |
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[24px] border-2 border-dashed border-stone-900 bg-white/85 text-sm font-medium text-stone-900 backdrop-blur-[1px] sm:rounded-[32px]">
|
| 535 |
<div className="flex items-center gap-2 rounded-full bg-stone-950 px-4 py-2 text-white shadow-lg">
|
|
|
|
| 554 |
>
|
| 555 |
<ImagePlus className="size-3.5 sm:size-4" />
|
| 556 |
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
{availableQuota !== "--" && (
|
| 558 |
onBenefitsClick ? (
|
| 559 |
<Button
|
|
|
|
| 741 |
</div>
|
| 742 |
)}
|
| 743 |
<div className="flex h-9 shrink-0 items-center gap-1 rounded-full border border-stone-200 bg-white px-2 py-0.5 sm:h-auto sm:px-2.5 sm:py-1">
|
| 744 |
+
<span className="hidden text-[11px] font-medium text-stone-700 sm:inline sm:text-sm">张数</span>
|
|
|
|
|
|
|
| 745 |
<Input
|
| 746 |
type="number"
|
| 747 |
inputMode="numeric"
|
|
|
|
| 905 |
</>
|
| 906 |
}
|
| 907 |
actions={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 908 |
<>
|
| 909 |
<output
|
| 910 |
data-testid="estimated-credit-cost"
|
|
|
|
| 928 |
label={referenceImages.length > 0 ? "编辑图片" : "生成图片"}
|
| 929 |
/>
|
| 930 |
</>
|
|
|
|
| 931 |
}
|
| 932 |
/>
|
| 933 |
</div>
|
web/src/app/image/page.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
| 10 |
buildImageExpansionPrompt,
|
| 11 |
type ImageEditorAction,
|
| 12 |
} from "@/app/image/components/image-annotation";
|
| 13 |
-
import { ImageComposer
|
| 14 |
import { ImageResults, type ImageLightboxItem } from "@/app/image/components/image-results";
|
| 15 |
import { ConversationSidebar, type ConversationSidebarItem } from "@/components/chat/conversation-sidebar";
|
| 16 |
import { ImageLightbox } from "@/components/image-lightbox";
|
|
@@ -575,10 +575,6 @@ function taskDataToStoredImages(image: StoredImage, task: ImageTask): StoredImag
|
|
| 575 |
conversationId: task.conversation_id || undefined,
|
| 576 |
durationMs: task.duration_ms,
|
| 577 |
canContinueWaiting: task.can_continue_waiting ?? Boolean(task.conversation_id),
|
| 578 |
-
// 「查看官网对话」入口:后端明确说「官网没有可核对的东西」(账号生图配额耗尽)时为
|
| 579 |
-
// false,卡片据此收掉入口。写成显式布尔值(而不是只在 false 时赋值),回填一次就稳定,
|
| 580 |
-
// 不会每次加载都重新拉这个任务。
|
| 581 |
-
canViewOfficial: task.can_view_official !== false,
|
| 582 |
}];
|
| 583 |
}
|
| 584 |
|
|
@@ -616,7 +612,6 @@ function hasStoredImageChanged(current: StoredImage, next: StoredImage) {
|
|
| 616 |
current.revised_prompt !== next.revised_prompt ||
|
| 617 |
current.error !== next.error ||
|
| 618 |
current.upstreamNote !== next.upstreamNote ||
|
| 619 |
-
current.canViewOfficial !== next.canViewOfficial ||
|
| 620 |
current.elapsedSecs !== next.elapsedSecs ||
|
| 621 |
current.durationMs !== next.durationMs
|
| 622 |
);
|
|
@@ -661,21 +656,11 @@ function mergeImageTaskResults(images: StoredImage[], taskMap: Map<string, Image
|
|
| 661 |
(task.conversation_id || task.upstream_note) &&
|
| 662 |
(!image.conversationId || !image.upstreamNote),
|
| 663 |
);
|
| 664 |
-
// 「查看官网」入口也有同样的问题:老卡片存的是 undefined,任务里已经有明确结论时
|
| 665 |
-
// 要回填一次(true/false 都会被写死),否则配额类报错的老卡片会一直挂着没用的按钮。
|
| 666 |
-
const shouldBackfillViewOfficial = Boolean(
|
| 667 |
-
image.status === "error" &&
|
| 668 |
-
task &&
|
| 669 |
-
task.status === "error" &&
|
| 670 |
-
image.taskId &&
|
| 671 |
-
image.canViewOfficial === undefined,
|
| 672 |
-
);
|
| 673 |
if (
|
| 674 |
!task ||
|
| 675 |
(image.status !== "loading" &&
|
| 676 |
!(image.status === "success" && task.status === "success" && image.taskId) &&
|
| 677 |
-
!shouldBackfillErrorMeta
|
| 678 |
-
!shouldBackfillViewOfficial)
|
| 679 |
) {
|
| 680 |
nextImages.push(image);
|
| 681 |
continue;
|
|
@@ -779,10 +764,7 @@ async function syncConversationImageTasks(items: ImageConversation[]) {
|
|
| 779 |
(image.status === "success" && !image.b64_json && !image.url) ||
|
| 780 |
// 失败图缺上游会话 id / 官网原话时也拉一次任务做回填(见 mergeImageTaskResults 的
|
| 781 |
// shouldBackfillErrorMeta);补齐后此条件不再命中,不会反复拉。
|
| 782 |
-
(image.status === "error" && (!image.conversationId || !image.upstreamNote))
|
| 783 |
-
// 「查看官网」入口同理:老卡片存的是 undefined,回填后会写成显式布尔值,
|
| 784 |
-
// 条件随即不再命中(任务确实取不到时保持原样,和上一条同一个兜底口径)。
|
| 785 |
-
(image.status === "error" && image.canViewOfficial === undefined)
|
| 786 |
) ? [image.taskId] : [],
|
| 787 |
),
|
| 788 |
),
|
|
@@ -897,7 +879,6 @@ function conversationsRecoveredFromServerTasks(tasks: ImageTask[]): ImageConvers
|
|
| 897 |
// 失败恢复的旧任务同样要带上游会话 id / 官网原话,和 taskDataToStoredImages 的 error 分支保持一致。
|
| 898 |
conversationId: task.conversation_id || undefined,
|
| 899 |
upstreamNote: task.upstream_note || undefined,
|
| 900 |
-
canViewOfficial: task.can_view_official !== false,
|
| 901 |
durationMs: task.duration_ms,
|
| 902 |
}));
|
| 903 |
if (images.length === 0) {
|
|
@@ -908,7 +889,6 @@ function conversationsRecoveredFromServerTasks(tasks: ImageTask[]): ImageConvers
|
|
| 908 |
error: task.error,
|
| 909 |
conversationId: task.conversation_id || undefined,
|
| 910 |
upstreamNote: task.upstream_note || undefined,
|
| 911 |
-
canViewOfficial: task.can_view_official !== false,
|
| 912 |
durationMs: task.duration_ms,
|
| 913 |
});
|
| 914 |
}
|
|
@@ -2997,123 +2977,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
|
|
| 2997 |
}
|
| 2998 |
};
|
| 2999 |
|
| 3000 |
-
/**
|
| 3001 |
-
* 批量生图提交:每一行展开成一个独立的 turn(复用现有单张任务链路)。
|
| 3002 |
-
* 现有会话队列会顺序处理这些 queued turn,结果照常落在消息流里。
|
| 3003 |
-
* 返回 false 表示校验未通过,批量输入卡保持原样。
|
| 3004 |
-
*/
|
| 3005 |
-
const handleBatchSubmit = async (rows: BatchRowInput[]): Promise<boolean> => {
|
| 3006 |
-
const validRows = rows
|
| 3007 |
-
.map((row) => ({
|
| 3008 |
-
prompt: String(row?.prompt || "").trim(),
|
| 3009 |
-
count: Math.max(1, Math.min(100, Math.floor(Number(row?.count) || 1))),
|
| 3010 |
-
}))
|
| 3011 |
-
.filter((row) => row.prompt.length > 0);
|
| 3012 |
-
if (validRows.length === 0) {
|
| 3013 |
-
toast.error("请至少填写一行提示词");
|
| 3014 |
-
return false;
|
| 3015 |
-
}
|
| 3016 |
-
const totalCount = validRows.reduce((sum, row) => sum + row.count, 0);
|
| 3017 |
-
|
| 3018 |
-
const effectiveImageMode: ImageConversationMode = referenceImageFiles.length > 0 ? "edit" : "generate";
|
| 3019 |
-
const requestedResolution = ["1k", "2k", "4k"].includes(imageResolution) ? imageResolution : "1k";
|
| 3020 |
-
const effectiveThirdPartyModel = selectedThirdPartyModel;
|
| 3021 |
-
const useThirdPartyImageApi = Boolean(effectiveThirdPartyModel);
|
| 3022 |
-
if ((imageResolution === "4k" || thirdPartyImageGroupId) && !effectiveThirdPartyModel) {
|
| 3023 |
-
toast.error(imageResolution === "4k" ? "后台还没有为当前用户开启可用的 4K 生图 API" : "这个 API 模型当前不可用,请在后台确认模型已启用");
|
| 3024 |
-
return false;
|
| 3025 |
-
}
|
| 3026 |
-
if (imageModel.trim().toLowerCase() === "gpt-image-2-4k" && !imageSize.trim()) {
|
| 3027 |
-
toast.error("使用 gpt-image-2-4k 时必须先选择图片比例");
|
| 3028 |
-
return false;
|
| 3029 |
-
}
|
| 3030 |
-
const batchEstimatedCredits = Math.round(selectedUnitCreditCost * totalCount * 100) / 100;
|
| 3031 |
-
if (isNormalUser && useThirdPartyImageApi) {
|
| 3032 |
-
if (!canAffordThirdPartyImages(creditSummary, effectiveThirdPartyModel, totalCount)) {
|
| 3033 |
-
toast.error("剩余可用积分不足,请先充值");
|
| 3034 |
-
setIsRedeemOpen(true);
|
| 3035 |
-
return false;
|
| 3036 |
-
}
|
| 3037 |
-
}
|
| 3038 |
-
if (isNormalUser && !useThirdPartyImageApi && (creditSummary?.credits ?? 0) < batchEstimatedCredits) {
|
| 3039 |
-
toast.error("剩余积分不足,请先充值");
|
| 3040 |
-
setIsRedeemOpen(true);
|
| 3041 |
-
return false;
|
| 3042 |
-
}
|
| 3043 |
-
if (
|
| 3044 |
-
isNormalUser &&
|
| 3045 |
-
!useThirdPartyImageApi &&
|
| 3046 |
-
!canFallbackToStandardImageApi &&
|
| 3047 |
-
accountPoolQuotaCount !== null &&
|
| 3048 |
-
accountPoolQuotaCount < totalCount
|
| 3049 |
-
) {
|
| 3050 |
-
const siteCredits = toCreditAmount(creditSummary?.credits) ?? 0;
|
| 3051 |
-
if (siteCredits < batchEstimatedCredits) {
|
| 3052 |
-
toast.error("剩余积分不足,请先充值");
|
| 3053 |
-
setIsRedeemOpen(true);
|
| 3054 |
-
} else {
|
| 3055 |
-
toast.error("号池可用生图额度不足,请提醒站长补充账号");
|
| 3056 |
-
}
|
| 3057 |
-
return false;
|
| 3058 |
-
}
|
| 3059 |
-
|
| 3060 |
-
const targetConversation = selectedConversationId
|
| 3061 |
-
? conversationsRef.current.find((conversation) => conversation.id === selectedConversationId) ?? null
|
| 3062 |
-
: null;
|
| 3063 |
-
const effectiveThirdPartyResolution = effectiveThirdPartyModel ? thirdPartyModelResolution(effectiveThirdPartyModel) : requestedResolution;
|
| 3064 |
-
const effectiveThirdPartyQuality = effectiveThirdPartyModel
|
| 3065 |
-
? thirdPartyModelDefaultQuality(effectiveThirdPartyModel)
|
| 3066 |
-
: imageQuality;
|
| 3067 |
-
const now = new Date().toISOString();
|
| 3068 |
-
const conversationId = targetConversation?.id ?? createId();
|
| 3069 |
-
|
| 3070 |
-
const batchTurns: ImageTurn[] = validRows.map((row) => {
|
| 3071 |
-
const turnId = createId();
|
| 3072 |
-
return {
|
| 3073 |
-
id: turnId,
|
| 3074 |
-
prompt: row.prompt,
|
| 3075 |
-
model: useThirdPartyImageApi ? effectiveThirdPartyModel?.id || imageModel : "gpt-image-2",
|
| 3076 |
-
mode: effectiveImageMode,
|
| 3077 |
-
referenceImages: effectiveImageMode === "edit" ? referenceImages : [],
|
| 3078 |
-
count: row.count,
|
| 3079 |
-
size: imageSize,
|
| 3080 |
-
quality: useThirdPartyImageApi ? effectiveThirdPartyQuality : undefined,
|
| 3081 |
-
resolution: useThirdPartyImageApi ? effectiveThirdPartyResolution : undefined,
|
| 3082 |
-
thirdPartyImageGroupId: useThirdPartyImageApi ? effectiveThirdPartyModel?.group_id : undefined,
|
| 3083 |
-
memoryEnabled: isMemoryEnabled,
|
| 3084 |
-
images: createLoadingImages(turnId, row.count),
|
| 3085 |
-
createdAt: now,
|
| 3086 |
-
status: "queued",
|
| 3087 |
-
};
|
| 3088 |
-
});
|
| 3089 |
-
|
| 3090 |
-
const baseConversation: ImageConversation = targetConversation
|
| 3091 |
-
? {
|
| 3092 |
-
...targetConversation,
|
| 3093 |
-
updatedAt: now,
|
| 3094 |
-
turns: [...targetConversation.turns, ...batchTurns],
|
| 3095 |
-
}
|
| 3096 |
-
: {
|
| 3097 |
-
id: conversationId,
|
| 3098 |
-
title: buildConversationTitle(validRows[0].prompt),
|
| 3099 |
-
createdAt: now,
|
| 3100 |
-
updatedAt: now,
|
| 3101 |
-
turns: batchTurns,
|
| 3102 |
-
};
|
| 3103 |
-
|
| 3104 |
-
shouldStickToBottomRef.current = true;
|
| 3105 |
-
setScrollToLatestVisible(false);
|
| 3106 |
-
setSelectedConversationId(conversationId);
|
| 3107 |
-
|
| 3108 |
-
await persistConversation(baseConversation);
|
| 3109 |
-
clearComposerInputs();
|
| 3110 |
-
void clearImageComposerDraft();
|
| 3111 |
-
void runConversationQueue(conversationId);
|
| 3112 |
-
|
| 3113 |
-
toast.success(`已提交 ${validRows.length} 行,共 ${totalCount} 张`);
|
| 3114 |
-
return true;
|
| 3115 |
-
};
|
| 3116 |
-
|
| 3117 |
const handleRedeem = async () => {
|
| 3118 |
const normalizedCode = redeemValue.trim();
|
| 3119 |
if (!normalizedCode) {
|
|
@@ -3609,7 +3472,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
|
|
| 3609 |
onMemoryToggle={() => setIsMemoryEnabled((value) => !value)}
|
| 3610 |
onAccountPoolStrategyChange={setAccountPoolStrategy}
|
| 3611 |
onSubmit={handleSubmit}
|
| 3612 |
-
onBatchSubmit={handleBatchSubmit}
|
| 3613 |
onOptimizePrompt={handleOptimizePrompt}
|
| 3614 |
isOptimizingPrompt={isOptimizingPrompt}
|
| 3615 |
onBenefitsClick={isNormalUser ? () => setIsBenefitsOpen(true) : undefined}
|
|
|
|
| 10 |
buildImageExpansionPrompt,
|
| 11 |
type ImageEditorAction,
|
| 12 |
} from "@/app/image/components/image-annotation";
|
| 13 |
+
import { ImageComposer } from "@/app/image/components/image-composer";
|
| 14 |
import { ImageResults, type ImageLightboxItem } from "@/app/image/components/image-results";
|
| 15 |
import { ConversationSidebar, type ConversationSidebarItem } from "@/components/chat/conversation-sidebar";
|
| 16 |
import { ImageLightbox } from "@/components/image-lightbox";
|
|
|
|
| 575 |
conversationId: task.conversation_id || undefined,
|
| 576 |
durationMs: task.duration_ms,
|
| 577 |
canContinueWaiting: task.can_continue_waiting ?? Boolean(task.conversation_id),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
}];
|
| 579 |
}
|
| 580 |
|
|
|
|
| 612 |
current.revised_prompt !== next.revised_prompt ||
|
| 613 |
current.error !== next.error ||
|
| 614 |
current.upstreamNote !== next.upstreamNote ||
|
|
|
|
| 615 |
current.elapsedSecs !== next.elapsedSecs ||
|
| 616 |
current.durationMs !== next.durationMs
|
| 617 |
);
|
|
|
|
| 656 |
(task.conversation_id || task.upstream_note) &&
|
| 657 |
(!image.conversationId || !image.upstreamNote),
|
| 658 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
if (
|
| 660 |
!task ||
|
| 661 |
(image.status !== "loading" &&
|
| 662 |
!(image.status === "success" && task.status === "success" && image.taskId) &&
|
| 663 |
+
!shouldBackfillErrorMeta)
|
|
|
|
| 664 |
) {
|
| 665 |
nextImages.push(image);
|
| 666 |
continue;
|
|
|
|
| 764 |
(image.status === "success" && !image.b64_json && !image.url) ||
|
| 765 |
// 失败图缺上游会话 id / 官网原话时也拉一次任务做回填(见 mergeImageTaskResults 的
|
| 766 |
// shouldBackfillErrorMeta);补齐后此条件不再命中,不会反复拉。
|
| 767 |
+
(image.status === "error" && (!image.conversationId || !image.upstreamNote))
|
|
|
|
|
|
|
|
|
|
| 768 |
) ? [image.taskId] : [],
|
| 769 |
),
|
| 770 |
),
|
|
|
|
| 879 |
// 失败恢复的旧任务同样要带上游会话 id / 官网原话,和 taskDataToStoredImages 的 error 分支保持一致。
|
| 880 |
conversationId: task.conversation_id || undefined,
|
| 881 |
upstreamNote: task.upstream_note || undefined,
|
|
|
|
| 882 |
durationMs: task.duration_ms,
|
| 883 |
}));
|
| 884 |
if (images.length === 0) {
|
|
|
|
| 889 |
error: task.error,
|
| 890 |
conversationId: task.conversation_id || undefined,
|
| 891 |
upstreamNote: task.upstream_note || undefined,
|
|
|
|
| 892 |
durationMs: task.duration_ms,
|
| 893 |
});
|
| 894 |
}
|
|
|
|
| 2977 |
}
|
| 2978 |
};
|
| 2979 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2980 |
const handleRedeem = async () => {
|
| 2981 |
const normalizedCode = redeemValue.trim();
|
| 2982 |
if (!normalizedCode) {
|
|
|
|
| 3472 |
onMemoryToggle={() => setIsMemoryEnabled((value) => !value)}
|
| 3473 |
onAccountPoolStrategyChange={setAccountPoolStrategy}
|
| 3474 |
onSubmit={handleSubmit}
|
|
|
|
| 3475 |
onOptimizePrompt={handleOptimizePrompt}
|
| 3476 |
isOptimizingPrompt={isOptimizingPrompt}
|
| 3477 |
onBenefitsClick={isNormalUser ? () => setIsBenefitsOpen(true) : undefined}
|
web/src/components/prompt-composer.tsx
CHANGED
|
@@ -345,7 +345,6 @@ type PromptComposerSubmitButtonProps = {
|
|
| 345 |
label: string;
|
| 346 |
title?: string;
|
| 347 |
className?: string;
|
| 348 |
-
testId?: string;
|
| 349 |
};
|
| 350 |
|
| 351 |
export function PromptComposerSubmitButton({
|
|
@@ -356,7 +355,6 @@ export function PromptComposerSubmitButton({
|
|
| 356 |
label,
|
| 357 |
title = "发送(Enter)",
|
| 358 |
className,
|
| 359 |
-
testId,
|
| 360 |
}: PromptComposerSubmitButtonProps) {
|
| 361 |
return (
|
| 362 |
<button
|
|
@@ -367,7 +365,6 @@ export function PromptComposerSubmitButton({
|
|
| 367 |
else void onSubmit();
|
| 368 |
}}
|
| 369 |
disabled={isSubmitting ? !onStop : disabled}
|
| 370 |
-
data-testid={testId}
|
| 371 |
className={cn(
|
| 372 |
"inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-stone-950 text-white transition-[transform,background-color] duration-150 ease-out hover:bg-stone-800 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-300 disabled:cursor-not-allowed disabled:bg-stone-300 disabled:opacity-100 sm:size-11 motion-reduce:transition-none",
|
| 373 |
className,
|
|
|
|
| 345 |
label: string;
|
| 346 |
title?: string;
|
| 347 |
className?: string;
|
|
|
|
| 348 |
};
|
| 349 |
|
| 350 |
export function PromptComposerSubmitButton({
|
|
|
|
| 355 |
label,
|
| 356 |
title = "发送(Enter)",
|
| 357 |
className,
|
|
|
|
| 358 |
}: PromptComposerSubmitButtonProps) {
|
| 359 |
return (
|
| 360 |
<button
|
|
|
|
| 365 |
else void onSubmit();
|
| 366 |
}}
|
| 367 |
disabled={isSubmitting ? !onStop : disabled}
|
|
|
|
| 368 |
className={cn(
|
| 369 |
"inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-stone-950 text-white transition-[transform,background-color] duration-150 ease-out hover:bg-stone-800 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-300 disabled:cursor-not-allowed disabled:bg-stone-300 disabled:opacity-100 sm:size-11 motion-reduce:transition-none",
|
| 370 |
className,
|