merge: official v1.5.4 release
This commit is contained in:
@@ -164,6 +164,7 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T
|
||||
colorLinkHover: color.solidHoverBg,
|
||||
colorLinkActive: color.solidActiveBg,
|
||||
colorTextLightSolid: color.solidFg,
|
||||
controlOutlineWidth: 0,
|
||||
colorText: skin.text || undefined,
|
||||
colorTextSecondary: skin.textMuted || undefined,
|
||||
colorIcon: skin.icon || undefined,
|
||||
@@ -196,6 +197,7 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultShadow: "none",
|
||||
primaryShadow: "none",
|
||||
dangerShadow: "none",
|
||||
dangerColor: color.dangerForeground,
|
||||
@@ -235,6 +237,11 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T
|
||||
hoverBorderColor: interactiveBorder,
|
||||
activeShadow: focusShadow,
|
||||
},
|
||||
DatePicker: {
|
||||
activeShadow: focusShadow,
|
||||
activeBorderColor: interactiveBorder,
|
||||
hoverBorderColor: interactiveBorder,
|
||||
},
|
||||
Switch: {
|
||||
handleBg: color.switchOffHandle,
|
||||
handleShadow: dark ? "0 1px 4px rgba(0, 0, 0, 0.42)" : "0 1px 2px rgba(0, 0, 0, 0.2)",
|
||||
@@ -394,7 +401,7 @@ export function getWorkspaceAntThemeConfig(): ThemeConfig {
|
||||
defaultShadow: "none", primaryShadow: "none", dangerShadow: "none",
|
||||
paddingInline: 14, paddingInlineSM: 10,
|
||||
},
|
||||
Input: { borderRadius: 12, activeShadow: "0 0 0 2px var(--user-accent-soft)", paddingInline: 12 },
|
||||
Input: { borderRadius: 12, paddingInline: 12 },
|
||||
Select: { borderRadius: 12, optionSelectedBg: "var(--user-accent-soft)", optionSelectedColor: "var(--user-ink)", optionActiveBg: "var(--user-surface-muted)", optionPadding: "9px 12px" },
|
||||
Dropdown: { borderRadiusLG: 16, controlItemBgHover: "var(--user-surface-muted)", paddingBlock: 6 },
|
||||
Modal: { borderRadiusLG: 20 },
|
||||
|
||||
@@ -5,82 +5,17 @@ import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow,
|
||||
export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。";
|
||||
export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图片,保留主体身份和关键产品细节,画面构图完整,光影自然。";
|
||||
export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:";
|
||||
export const BATCH_REFERENCE_HANDLE_TOP = 112;
|
||||
export const BATCH_REFERENCE_HANDLE_GAP = 38;
|
||||
export const MAX_BATCH_REFERENCE_COLUMNS = 10;
|
||||
export const MAX_BATCH_TEXT_COLUMNS = 4;
|
||||
export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:";
|
||||
/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1)之后,对准表头中线。 */
|
||||
export const BATCH_REFERENCE_HANDLE_TOP = 102;
|
||||
export const BATCH_REFERENCE_HANDLE_GAP = 40;
|
||||
export const MIN_BATCH_REFERENCE_COLUMNS = 1;
|
||||
export const MAX_BATCH_REFERENCE_COLUMNS = 6;
|
||||
const LEGACY_BATCH_TABLE_WIDTH = 900;
|
||||
|
||||
export function batchGridTemplateColumns(referenceCount: number, textCount: number) {
|
||||
return [
|
||||
"36px",
|
||||
"68px",
|
||||
`repeat(${referenceCount}, 72px)`,
|
||||
textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined,
|
||||
"minmax(280px, 1fr)",
|
||||
"148px",
|
||||
"36px",
|
||||
].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData {
|
||||
const columns = batchReferenceColumns(table);
|
||||
const fromIndex = columns.findIndex((column) => column.id === fromColumnId);
|
||||
const toIndex = columns.findIndex((column) => column.id === toColumnId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table;
|
||||
|
||||
const nextColumns = [...columns];
|
||||
const [moved] = nextColumns.splice(fromIndex, 1);
|
||||
nextColumns.splice(toIndex, 0, moved);
|
||||
const nextRows = table.rows.map((row) => ({
|
||||
...row,
|
||||
inputNodeIds: nextColumns.map((column) => {
|
||||
const oldIndex = columns.findIndex((item) => item.id === column.id);
|
||||
return row.inputNodeIds[oldIndex] || "";
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
...table,
|
||||
referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })),
|
||||
rows: nextRows,
|
||||
};
|
||||
}
|
||||
|
||||
export function moveBatchReferenceCell(
|
||||
table: CanvasBatchTableData,
|
||||
sourceRowId: string,
|
||||
sourceColumnIndex: number,
|
||||
targetRowId: string,
|
||||
targetColumnIndex: number,
|
||||
): CanvasBatchTableData {
|
||||
if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table;
|
||||
if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table;
|
||||
|
||||
const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1);
|
||||
const sourceRow = table.rows.find((row) => row.id === sourceRowId);
|
||||
const targetRow = table.rows.find((row) => row.id === targetRowId);
|
||||
if (!sourceRow || !targetRow) return table;
|
||||
|
||||
const nextRows = table.rows.map((row) => ({
|
||||
...row,
|
||||
inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || ""),
|
||||
}));
|
||||
const nextSourceRow = nextRows.find((row) => row.id === sourceRowId)!;
|
||||
const nextTargetRow = nextRows.find((row) => row.id === targetRowId)!;
|
||||
const sourceNodeId = nextSourceRow.inputNodeIds[sourceColumnIndex];
|
||||
if (!sourceNodeId) return table;
|
||||
|
||||
const targetNodeId = nextTargetRow.inputNodeIds[targetColumnIndex];
|
||||
nextTargetRow.inputNodeIds[targetColumnIndex] = sourceNodeId;
|
||||
nextSourceRow.inputNodeIds[sourceColumnIndex] = targetNodeId || "";
|
||||
|
||||
return { ...table, rows: nextRows };
|
||||
}
|
||||
|
||||
// Reserve one slot for adding a reference and a separate gap before text inputs.
|
||||
export function batchTextHandleTop(referenceCount: number) {
|
||||
return BATCH_REFERENCE_HANDLE_TOP + (referenceCount + 1) * BATCH_REFERENCE_HANDLE_GAP + 24;
|
||||
/** 旧默认 900 宽的批量创作表升级到当前默认尺寸,已经手动改过宽度的节点保持原样。 */
|
||||
export function promoteLegacyBatchTableSize(node: CanvasNodeData): CanvasNodeData {
|
||||
if (node.type !== "batch-table" || node.width !== LEGACY_BATCH_TABLE_WIDTH) return node;
|
||||
return { ...node, width: 1280, height: Math.max(node.height, 560) };
|
||||
}
|
||||
|
||||
export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
|
||||
@@ -94,10 +29,6 @@ export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
|
||||
export function batchReferenceColumns(table?: CanvasBatchTableData) {
|
||||
const columns = table?.referenceColumns;
|
||||
if (!columns?.length) return defaultBatchReferenceColumns();
|
||||
// Older tables were created before the second default slot existed. They
|
||||
// never had a remove-column action, so extending this exact legacy shape is
|
||||
// a safe migration and makes reference slot 2 immediately selectable.
|
||||
if (columns.length === 1 && columns[0].id === "reference-1") return defaultBatchReferenceColumns();
|
||||
return columns;
|
||||
}
|
||||
|
||||
@@ -105,47 +36,16 @@ export function batchReferenceHandleId(columnId: string) {
|
||||
return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`;
|
||||
}
|
||||
|
||||
export function batchReferenceMentionToken(index: number) {
|
||||
return `@参考图${index + 1}`;
|
||||
}
|
||||
|
||||
export function batchReferenceColumnId(handleId?: string) {
|
||||
return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined;
|
||||
}
|
||||
|
||||
export function batchTextColumns(table?: CanvasBatchTableData) {
|
||||
return table?.textColumns || [];
|
||||
}
|
||||
|
||||
export function batchTextHandleId(columnId: string) {
|
||||
return `${BATCH_TEXT_HANDLE_PREFIX}${columnId}`;
|
||||
}
|
||||
|
||||
export function batchTextColumnId(handleId?: string) {
|
||||
return handleId?.startsWith(BATCH_TEXT_HANDLE_PREFIX) ? handleId.slice(BATCH_TEXT_HANDLE_PREFIX.length) : undefined;
|
||||
}
|
||||
|
||||
export function batchTextHandleY(node: CanvasNodeData, handleId?: string) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const columnId = batchTextColumnId(handleId);
|
||||
const columns = batchTextColumns(node.metadata?.batchTable);
|
||||
const index = columnId ? columns.findIndex((column) => column.id === columnId) : -1;
|
||||
if (index < 0) return undefined;
|
||||
return node.position.y + batchTextHandleTop(batchReferenceColumns(node.metadata?.batchTable).length) + index * BATCH_REFERENCE_HANDLE_GAP;
|
||||
}
|
||||
|
||||
export function batchTextHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const columns = batchTextColumns(node.metadata?.batchTable);
|
||||
const referenceCount = batchReferenceColumns(node.metadata?.batchTable).length;
|
||||
let nearestIndex = -1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
columns.forEach((_, index) => {
|
||||
const distance = Math.abs(worldY - (node.position.y + batchTextHandleTop(referenceCount) + index * BATCH_REFERENCE_HANDLE_GAP));
|
||||
if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; }
|
||||
});
|
||||
return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchTextHandleId(columns[nearestIndex].id) : undefined;
|
||||
}
|
||||
export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const textY = batchTextHandleY(node, handleId);
|
||||
if (textY !== undefined) return textY;
|
||||
const columnId = batchReferenceColumnId(handleId);
|
||||
const columns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
const index = columnId ? columns.findIndex((column) => column.id === columnId) : 0;
|
||||
@@ -155,17 +55,71 @@ export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
|
||||
|
||||
export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
|
||||
if (node.type !== "batch-table") return undefined;
|
||||
const referenceColumns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
const textColumns = batchTextColumns(node.metadata?.batchTable);
|
||||
const handles = [
|
||||
...referenceColumns.map((column, index) => ({ id: batchReferenceHandleId(column.id), y: BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP })),
|
||||
...textColumns.map((column, index) => ({ id: batchTextHandleId(column.id), y: batchTextHandleTop(referenceColumns.length) + index * BATCH_REFERENCE_HANDLE_GAP })),
|
||||
];
|
||||
const nearest = handles.reduce<{ id: string; distance: number } | null>((current, handle) => {
|
||||
const distance = Math.abs(worldY - (node.position.y + handle.y));
|
||||
return !current || distance < current.distance ? { id: handle.id, distance } : current;
|
||||
}, null);
|
||||
return nearest && nearest.distance <= hitRadius ? nearest.id : undefined;
|
||||
const columns = batchReferenceColumns(node.metadata?.batchTable);
|
||||
let nearestIndex = -1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
columns.forEach((_, index) => {
|
||||
const distance = Math.abs(worldY - (node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP));
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchReferenceHandleId(columns[nearestIndex].id) : undefined;
|
||||
}
|
||||
|
||||
export function removeLastBatchReferenceColumn(table: CanvasBatchTableData): CanvasBatchTableData | null {
|
||||
const columns = batchReferenceColumns(table);
|
||||
if (columns.length <= MIN_BATCH_REFERENCE_COLUMNS) return null;
|
||||
const nextColumns = columns.slice(0, -1).map((column, index) => ({ ...column, label: `参考图 ${index + 1}` }));
|
||||
return {
|
||||
...table,
|
||||
referenceColumns: nextColumns,
|
||||
rows: table.rows.map((row) => ({ ...row, inputNodeIds: nextColumns.map((_, index) => row.inputNodeIds[index] || "") })),
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData {
|
||||
const columns = batchReferenceColumns(table);
|
||||
const fromIndex = columns.findIndex((column) => column.id === fromColumnId);
|
||||
const toIndex = columns.findIndex((column) => column.id === toColumnId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table;
|
||||
const nextColumns = [...columns];
|
||||
const [moved] = nextColumns.splice(fromIndex, 1);
|
||||
nextColumns.splice(toIndex, 0, moved);
|
||||
return {
|
||||
...table,
|
||||
referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })),
|
||||
rows: table.rows.map((row) => ({
|
||||
...row,
|
||||
inputNodeIds: nextColumns.map((column) => {
|
||||
const oldIndex = columns.findIndex((item) => item.id === column.id);
|
||||
return row.inputNodeIds[oldIndex] || "";
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function moveBatchReferenceCell(table: CanvasBatchTableData, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number): CanvasBatchTableData {
|
||||
if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table;
|
||||
if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table;
|
||||
const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1);
|
||||
const sourceRow = table.rows.find((row) => row.id === sourceRowId);
|
||||
const targetRow = table.rows.find((row) => row.id === targetRowId);
|
||||
if (!sourceRow || !targetRow) return table;
|
||||
const nextRows = table.rows.map((row) => ({ ...row, inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || "") }));
|
||||
const nextSource = nextRows.find((row) => row.id === sourceRowId)!;
|
||||
const nextTarget = nextRows.find((row) => row.id === targetRowId)!;
|
||||
const sourceNodeId = nextSource.inputNodeIds[sourceColumnIndex];
|
||||
if (!sourceNodeId) return table;
|
||||
const targetNodeId = nextTarget.inputNodeIds[targetColumnIndex];
|
||||
nextTarget.inputNodeIds[targetColumnIndex] = sourceNodeId;
|
||||
nextSource.inputNodeIds[sourceColumnIndex] = targetNodeId || "";
|
||||
return { ...table, rows: nextRows };
|
||||
}
|
||||
|
||||
export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
|
||||
return table.globalPrompt?.trim() || row.prompt;
|
||||
}
|
||||
|
||||
export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
|
||||
@@ -181,34 +135,20 @@ export function batchInputColumns(node: CanvasNodeData, connections: CanvasConne
|
||||
return result;
|
||||
}
|
||||
|
||||
export function batchTextInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
|
||||
const columns = batchTextColumns(node.metadata?.batchTable);
|
||||
const indexById = new Map(columns.map((column, index) => [column.id, index]));
|
||||
const result = columns.map(() => [] as string[]);
|
||||
connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").forEach((connection) => {
|
||||
const columnId = batchTextColumnId(connection.toHandleId);
|
||||
const index = columnId ? indexById.get(columnId) : undefined;
|
||||
if (index === undefined || result[index].includes(connection.fromNodeId)) return;
|
||||
result[index].push(connection.fromNodeId);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A non-empty global prompt takes precedence without destroying row-specific prompts. */
|
||||
export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
|
||||
return table.globalPrompt?.trim() || row.prompt;
|
||||
}
|
||||
|
||||
export function createBatchRow(operation: CanvasBatchOperation, inputNodeIds: string[] = []): CanvasBatchRow {
|
||||
return {
|
||||
id: `batch-row-${nanoid()}`,
|
||||
enabled: true,
|
||||
inputNodeIds,
|
||||
textNodeIds: [],
|
||||
prompt: operation === "try_on" ? TRY_ON_BATCH_PROMPT : CREATIVE_BATCH_PROMPT,
|
||||
};
|
||||
}
|
||||
|
||||
/** 手工追加任务时沿用上一行的参考图,但重新使用当前任务类型的默认提示词。 */
|
||||
export function createInheritedBatchRow(operation: CanvasBatchOperation, rows: CanvasBatchRow[]) {
|
||||
return createBatchRow(operation, [...(rows.at(-1)?.inputNodeIds || [])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected try-on inputs follow the reference layout: all model/person images first,
|
||||
* followed by one shared garment image. Users can still edit any row afterwards.
|
||||
@@ -220,32 +160,34 @@ export function createBatchRowsFromInputs(operation: CanvasBatchOperation, input
|
||||
return inputNodeIds.slice(0, -1).map((personId) => createBatchRow(operation, [personId, garmentId]));
|
||||
}
|
||||
|
||||
/** Zip equally sized columns and broadcast singleton columns across every row. */
|
||||
/**
|
||||
* Zip equally sized columns and broadcast singleton columns across every row.
|
||||
*
|
||||
* 同步连线是增量操作:匹配到的行更新参考图,未被本次连线覆盖的手工行继续保留。
|
||||
* 这样用户在表格内追加或编辑的任务不会因为再次点击“同步连线”而被删除。
|
||||
*/
|
||||
export function createBatchRowsFromColumns(operation: CanvasBatchOperation, columns: string[][], previousRows: CanvasBatchRow[] = []) {
|
||||
const rowCount = Math.max(0, ...columns.map((column) => column.length));
|
||||
const previousByPrimary = new Map(previousRows.flatMap((row) => row.inputNodeIds[0] ? [[row.inputNodeIds[0], row] as const] : []));
|
||||
return Array.from({ length: rowCount }, (_, rowIndex) => {
|
||||
const unmatchedRows = [...previousRows];
|
||||
const synchronizedRows = Array.from({ length: rowCount }, (_, rowIndex) => {
|
||||
const inputNodeIds = columns.flatMap((column) => {
|
||||
const input = column.length === 1 ? column[0] : column[rowIndex];
|
||||
return input ? [input] : [];
|
||||
});
|
||||
const previous = inputNodeIds[0] ? previousByPrimary.get(inputNodeIds[0]) : undefined;
|
||||
let previousIndex = unmatchedRows.findIndex((row) => sameBatchInputs(row.inputNodeIds, inputNodeIds));
|
||||
if (previousIndex < 0 && inputNodeIds[0]) previousIndex = unmatchedRows.findIndex((row) => row.inputNodeIds[0] === inputNodeIds[0]);
|
||||
const previous = previousIndex >= 0 ? unmatchedRows.splice(previousIndex, 1)[0] : undefined;
|
||||
const inputsUnchanged = previous && previous.inputNodeIds.length === inputNodeIds.length && previous.inputNodeIds.every((id, index) => id === inputNodeIds[index]);
|
||||
return {
|
||||
...createBatchRow(operation, inputNodeIds),
|
||||
...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt, textNodeIds: previous.textNodeIds } : {}),
|
||||
...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt } : {}),
|
||||
...(inputsUnchanged && previous?.outputNodeId ? { outputNodeId: previous.outputNodeId } : {}),
|
||||
inputNodeIds,
|
||||
};
|
||||
});
|
||||
return [...synchronizedRows, ...unmatchedRows];
|
||||
}
|
||||
|
||||
export function assignBatchTextRows(rows: CanvasBatchRow[], columns: string[][]) {
|
||||
return rows.map((row, rowIndex) => ({
|
||||
...row,
|
||||
textNodeIds: columns.flatMap((column) => {
|
||||
const input = column.length === 1 ? column[0] : column[rowIndex];
|
||||
return input ? [input] : [];
|
||||
}),
|
||||
}));
|
||||
function sameBatchInputs(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,20 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
|
||||
const children = (root.metadata.batchChildIds || [])
|
||||
.map((id) => nodeById.get(id))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id));
|
||||
if (!children.length) return root;
|
||||
if (!children.length) {
|
||||
const metadata: CanvasNodeMetadata = { ...root.metadata };
|
||||
delete metadata.batchChildIds;
|
||||
delete metadata.batchFailedCount;
|
||||
delete metadata.primaryImageId;
|
||||
delete metadata.isBatchRoot;
|
||||
delete metadata.imageBatchExpanded;
|
||||
delete metadata.errorDetails;
|
||||
delete metadata.generationErrorCode;
|
||||
delete metadata.resourceReloadAvailable;
|
||||
delete metadata.failedPromptFingerprint;
|
||||
if (!metadata.content) metadata.status = "idle";
|
||||
return { ...root, metadata };
|
||||
}
|
||||
|
||||
const primary = children.find((node) => node.id === root.metadata?.primaryImageId && node.metadata?.content) || children.find((node) => node.metadata?.content);
|
||||
const loading = children.some((node) => node.metadata?.status === "loading");
|
||||
|
||||
@@ -10,6 +10,7 @@ import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasNodeMentionToken, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table";
|
||||
import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { scopedLocalStorage } from "@/lib/user-scope";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
|
||||
@@ -430,7 +431,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set<str
|
||||
if (requestedIds.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => removedIds.add(childId));
|
||||
});
|
||||
const remainingNodes = nodes.filter((node) => !removedIds.has(node.id));
|
||||
const nextNodes = remainingNodes.map((node) => {
|
||||
const cleanedNodes = remainingNodes.map((node) => {
|
||||
const detached = node.parentId && removedIds.has(node.parentId) ? { ...node, parentId: undefined } : node;
|
||||
const storyboard = detached.metadata?.storyboard;
|
||||
const cleaned = storyboard
|
||||
@@ -458,6 +459,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set<str
|
||||
const batchRoot = { ...cleaned, metadata: { ...cleaned.metadata, batchChildIds: childIds, primaryImageId } };
|
||||
return primaryNode ? applyBatchPrimaryImage(batchRoot, primaryNode) : batchRoot;
|
||||
});
|
||||
const nextNodes = cleanedNodes.map((node) => reconcileImageBatchRoot(node, cleanedNodes));
|
||||
return { removedIds, nodes: nextNodes };
|
||||
}
|
||||
|
||||
|
||||
@@ -154,14 +154,7 @@ const BUILTIN_NODE_TRAITS = {
|
||||
showInCreateMenu: true,
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.BatchTable]: {
|
||||
label: "批量创作表",
|
||||
icon: <Table2 />,
|
||||
minSize: { width: 720, height: 360 },
|
||||
showInCreateMenu: true,
|
||||
acceptsInputKind: ["image", "text"],
|
||||
inputKind: "text",
|
||||
},
|
||||
[CanvasNodeType.BatchTable]: { label: "批量创作表", icon: <Table2 />, minSize: { width: 1080, height: 420 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null },
|
||||
[CanvasNodeType.MediaConversion]: { label: "转换", icon: <WandSparkles />, minSize: { width: 400, height: 360 }, showInCreateMenu: true, resourceKind: (node: CanvasNodeData) => { const c = node.metadata?.mediaConversion; return c?.status === "completed" && c.resultStorageKey ? (c.outputKind === "video" ? "video" : "image") : null; }, acceptsInputKind: ["image", "video"], maxInputCount: 1, inputKind: "image" },
|
||||
[CanvasNodeType.ColorGrade]: {
|
||||
label: "调色",
|
||||
|
||||
56
web/src/lib/model-picker-groups.ts
Normal file
56
web/src/lib/model-picker-groups.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { configuredModelDisplayName, groupModelsByDisplayName, type DisplayModelGroup } from "@/lib/model-selection";
|
||||
import { modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export type ModelPickerGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
scope: string;
|
||||
kind: "product" | "channel";
|
||||
models: DisplayModelGroup[];
|
||||
};
|
||||
|
||||
export function isDirectSystemModel(config: AiConfig, value: string) {
|
||||
if (!value) return false;
|
||||
const channel = resolveModelChannel(config, value);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
|
||||
return channel.scope === "system" && channel.id !== PUBLIC_MODEL_CATALOG_ID && !cost?.logicalModelId;
|
||||
}
|
||||
|
||||
export function modelChannelLabel(config: AiConfig, value: string) {
|
||||
const channel = resolveModelChannel(config, value);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
|
||||
return cost?.channelLabel?.trim() || channel.name || "未命名渠道";
|
||||
}
|
||||
|
||||
// 一级按模型展示名跨渠道聚合;二级保留每条渠道模型的独立选择值、能力和售价。
|
||||
export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] {
|
||||
const groups = new Map<string, ModelPickerGroup>();
|
||||
for (const channel of config.channels) {
|
||||
const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id);
|
||||
const directModels = models.filter((value) => isDirectSystemModel(config, value));
|
||||
for (const value of directModels) {
|
||||
const label = configuredModelDisplayName(config, value);
|
||||
const key = JSON.stringify(["product", label]);
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = { key, label, icon: modelIcon(config, value), scope: "平台服务", kind: "product", models: [] };
|
||||
groups.set(key, group);
|
||||
}
|
||||
group.models.push({ key: value, label: modelChannelLabel(config, value), models: [value] });
|
||||
}
|
||||
const otherModels = models.filter((value) => !isDirectSystemModel(config, value));
|
||||
if (otherModels.length) {
|
||||
const key = JSON.stringify(["channel", channel.id]);
|
||||
groups.set(key, {
|
||||
key,
|
||||
label: channel.name || "未命名渠道",
|
||||
icon: modelIcon(config, otherModels[0]),
|
||||
scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : "我的模型",
|
||||
kind: "channel",
|
||||
models: groupModelsByDisplayName(config, otherModels),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import { buildImageResolutionOptions, imageResolutionOption } from "@/lib/image-resolution-tiers";
|
||||
import type { ModelRequestIntent } from "@/services/api/logical-models";
|
||||
import type { LogicalModelQuote, ModelQuoteRequest, ModelRequestIntent } from "@/services/api/logical-models";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
export type ModelPriceTier = NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number];
|
||||
@@ -52,7 +52,7 @@ export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capabilit
|
||||
return matched;
|
||||
}
|
||||
|
||||
export function priceTierSummaryLabel(tiers: ModelPriceTier[]) {
|
||||
export function priceTierSummaryLabel(tiers: ModelPriceTier[], capability?: ModelCapability) {
|
||||
const unitPrices = (billingMode: ModelPriceTier["billingMode"]) =>
|
||||
tiers
|
||||
.filter((tier) => tier.billingMode === billingMode)
|
||||
@@ -60,23 +60,41 @@ export function priceTierSummaryLabel(tiers: ModelPriceTier[]) {
|
||||
.filter((value) => Number.isFinite(value) && value >= 0);
|
||||
const fixedRequestValues = unitPrices("fixed_request");
|
||||
const perSecondValues = unitPrices("per_second");
|
||||
if (fixedRequestValues.length) return formatPriceRange(fixedRequestValues, "积分");
|
||||
if (perSecondValues.length) return formatPriceRange(perSecondValues, "积分/秒");
|
||||
return tiers.some((tier) => tier.billingMode === "token") ? "按量预估" : "未配置";
|
||||
const tokenValues = tiers.filter((tier) => tier.billingMode === "token").map((tier) => tier.outputTokenPriceMicrocredits / 1_000_000).filter((value) => Number.isFinite(value) && value >= 0);
|
||||
return [
|
||||
fixedRequestValues.length ? formatPriceRange(fixedRequestValues, "积分") : "",
|
||||
perSecondValues.length ? formatPriceRange(perSecondValues, "积分/秒") : "",
|
||||
tokenValues.length ? `${capability === "video" ? "" : "输出 "}${formatPriceRange(tokenValues, capability === "video" ? "积分/百万视频 Token" : "积分/百万 Token")}` : "",
|
||||
].filter(Boolean).join(" · ") || "未配置";
|
||||
}
|
||||
|
||||
export function formatPriceRange(values: number[], suffix: string) {
|
||||
const unique = Array.from(new Set(values)).sort((left, right) => left - right);
|
||||
const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 });
|
||||
const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`;
|
||||
}
|
||||
|
||||
export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined {
|
||||
export function modelQuoteDescription(quote: LogicalModelQuote) {
|
||||
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const descriptions = [`${quote.estimated ? "预计" : "本次"}消耗 ${amount} 积分`];
|
||||
const estimate = quote.videoTokenEstimate;
|
||||
if (estimate) {
|
||||
descriptions.push(`公式预估 ${estimate.formulaTokens.toLocaleString("zh-CN")} 视频 Token,平台额外预留 ${estimate.reservationMarginPercent}%(共 ${estimate.reservedTokens.toLocaleString("zh-CN")} Token)`);
|
||||
if (estimate.referenceDurationEstimated) descriptions.push("未知的参考视频时长按 15 秒估算");
|
||||
if (estimate.dimensionsEstimated) descriptions.push("实际画幅尚未确定,尺寸按当前参数估算");
|
||||
}
|
||||
if (quote.billingMode === "token") descriptions.push(estimate
|
||||
? "预估金额并非费用上限,成功任务优先按有效的实际用量结算;未返回用量时按火山引擎公式结算,额外预留不计入公式结算,可能补扣或退回差额"
|
||||
: "预估金额并非费用上限,最终以成功任务返回的实际用量结算,可能补扣或退回差额");
|
||||
return descriptions.join(";");
|
||||
}
|
||||
|
||||
export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): ModelQuoteRequest | undefined {
|
||||
if (!capability || !value) return undefined;
|
||||
const channel = resolveModelChannel(config, value);
|
||||
if (channel.scope !== "system") return undefined;
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
|
||||
if (!cost?.logicalModelId) return undefined;
|
||||
if (!cost || (!cost.logicalModelId && capability !== "video")) return undefined;
|
||||
const input = requirements?.input;
|
||||
const intent: ModelRequestIntent = {
|
||||
capability,
|
||||
@@ -93,7 +111,7 @@ export function modelQuoteRequest(config: AiConfig, value: string, capability?:
|
||||
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
|
||||
},
|
||||
};
|
||||
return { logicalModelID: cost.logicalModelId, intent };
|
||||
return cost.logicalModelId ? { logicalModelID: cost.logicalModelId, intent } : { channelId: channel.id, modelKey: modelOptionName(value), intent };
|
||||
}
|
||||
|
||||
function creditAmount(billingMode: "fixed_request" | "per_second", unitPriceMicrocredits: number, count?: string | number, seconds?: string | number) {
|
||||
@@ -109,11 +127,14 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config
|
||||
const imageCount = (input.imageCount || 0) + (input.characterCount || 0);
|
||||
requested.operation = input.videoCount > 0 ? "video_to_video" : imageCount > 0 ? "image_to_video" : resolveVideoOperation(input, requirements?.videoOperation);
|
||||
if (imageCount > 0) requested.imageCount = String(imageCount);
|
||||
}
|
||||
const resolution = normalizeTierResolution(config.vquality);
|
||||
} else if (requirements?.videoOperation) requested.operation = requirements.videoOperation;
|
||||
const options: Record<string, unknown> = { ...modelRequestOptions(config, "video"), ...requirements?.options, ...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}) };
|
||||
const resolution = normalizeTierResolution(String(options.vquality ?? ""));
|
||||
if (resolution !== "*") requested.vquality = resolution;
|
||||
const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
|
||||
const seconds = Math.max(0, Math.floor(Number(options.videoSeconds) || 0));
|
||||
if (seconds > 0) requested.videoSeconds = String(seconds);
|
||||
if (options.videoGenerateAudio === true || options.videoGenerateAudio === "true") requested.videoGenerateAudio = "true";
|
||||
if (options.videoGenerateAudio === false || options.videoGenerateAudio === "false") requested.videoGenerateAudio = "false";
|
||||
}
|
||||
if (capability === "image") {
|
||||
requested.operation = imagePriceOperation(requirements);
|
||||
|
||||
@@ -17,8 +17,8 @@ export function isVolcengineArkVideoProtocol(protocol?: string) {
|
||||
return protocol === "volcengine-ark-video" || protocol === "volcengine-ark-agent-plan-video";
|
||||
}
|
||||
|
||||
export function modelProtocolSupportsTokenBilling(capability?: string, protocol?: string) {
|
||||
return capability === "text" || (capability === "video" && isVolcengineArkVideoProtocol(protocol));
|
||||
export function modelProtocolSupportsTokenBilling(capability?: string, _protocol?: string) {
|
||||
return capability === "text" || capability === "video";
|
||||
}
|
||||
|
||||
export function protocolForModelCatalog(_endpointTypes: string[] = []): ModelProtocol | undefined {
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
export type SkinThemeMode = "light" | "dark";
|
||||
export type SkinShadowStyle = "none" | "soft" | "strong";
|
||||
|
||||
export type SkinButtonFill = {
|
||||
mode: "solid" | "gradient";
|
||||
angle: number;
|
||||
start: string;
|
||||
end: string;
|
||||
hoverStart: string;
|
||||
hoverEnd: string;
|
||||
activeStart: string;
|
||||
activeEnd: string;
|
||||
foreground: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_BUTTON_GRADIENT: SkinButtonFill = {
|
||||
mode: "gradient", angle: 115,
|
||||
start: "#6554df", end: "#386fbc",
|
||||
hoverStart: "#5744cf", hoverEnd: "#356bbb",
|
||||
activeStart: "#4938b8", activeEnd: "#2c5da5", foreground: "#ffffff",
|
||||
};
|
||||
|
||||
export const SKIN_BUTTON_COLOR_FIELDS = [
|
||||
{ key: "start", label: "渐变起色" }, { key: "end", label: "渐变终色" },
|
||||
{ key: "hoverStart", label: "悬停起色" }, { key: "hoverEnd", label: "悬停终色" },
|
||||
{ key: "activeStart", label: "按下起色" }, { key: "activeEnd", label: "按下终色" },
|
||||
{ key: "foreground", label: "渐变按钮文字" },
|
||||
] as const;
|
||||
|
||||
export type SkinModeTokens = {
|
||||
canvas: string;
|
||||
surface: string;
|
||||
@@ -78,6 +104,7 @@ export type SkinTokens = {
|
||||
light: SkinModeTokens;
|
||||
dark: SkinModeTokens;
|
||||
components: SkinComponentTokens;
|
||||
buttons: Record<SkinThemeMode, SkinButtonFill>;
|
||||
};
|
||||
|
||||
export type SkinDefinition = {
|
||||
@@ -197,7 +224,7 @@ export const SKIN_COLOR_GROUPS: readonly SkinColorGroup[] = [
|
||||
{ key: "switchUnchecked", label: "开关关闭", help: "Switch 关闭轨道" },
|
||||
{ key: "switchUncheckedHover", label: "关闭悬停", help: "关闭状态 hover 轨道" },
|
||||
{ key: "switchUncheckedHandle", label: "关闭滑块", help: "关闭状态圆形滑块" },
|
||||
{ key: "primary", label: "主操作", help: "主要按钮、链接和开关" },
|
||||
{ key: "primary", label: "主操作", help: "纯色主按钮与链接;开关使用独立颜色" },
|
||||
{ key: "primaryHover", label: "主操作悬停", help: "主要操作 hover" },
|
||||
{ key: "primaryActive", label: "主操作按下", help: "主要操作 active" },
|
||||
{ key: "primaryForeground", label: "主操作前景", help: "主按钮上的文字和图标" },
|
||||
@@ -371,6 +398,7 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = {
|
||||
authAccent: "#93c5fd",
|
||||
authMuted: "#8a8b91",
|
||||
},
|
||||
buttons: { light: { ...DEFAULT_BUTTON_GRADIENT }, dark: { ...DEFAULT_BUTTON_GRADIENT } },
|
||||
components: {
|
||||
buttonRadius: 6,
|
||||
inputRadius: 6,
|
||||
@@ -395,6 +423,7 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = {
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i;
|
||||
const MANAGED_VARIABLES = [
|
||||
"--button-primary-bg", "--button-primary-hover-bg", "--button-primary-active-bg", "--button-primary-fg",
|
||||
"--background",
|
||||
"--foreground",
|
||||
"--card",
|
||||
@@ -504,7 +533,10 @@ export function normalizeSkinDefinition(value: unknown, fallback: SkinDefinition
|
||||
name: typeof candidate.name === "string" && candidate.name.trim() ? candidate.name.trim().slice(0, 40) : fallback.name,
|
||||
description: typeof candidate.description === "string" ? candidate.description.trim().slice(0, 100) : fallback.description,
|
||||
locked: id === "classic",
|
||||
tokens: { light, dark, components: { ...modes.components } },
|
||||
tokens: { light, dark, components: { ...modes.components }, buttons: {
|
||||
light: normalizeButtonFill(modes.buttons?.light, id),
|
||||
dark: normalizeButtonFill(modes.buttons?.dark, id),
|
||||
} },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -662,9 +694,37 @@ export function applySkinTheme(skinValue: unknown, mode: SkinThemeMode, targetDo
|
||||
const values = skinCSSVariables(skin, mode);
|
||||
for (const [property, value] of Object.entries(values)) root.style.setProperty(property, value);
|
||||
}
|
||||
for (const [property, value] of Object.entries(skinButtonCSSVariables(skin, mode))) root.style.setProperty(property, value);
|
||||
root.dataset.skin = skin.id;
|
||||
}
|
||||
|
||||
export function isSkinButtonFill(value: unknown): value is SkinButtonFill {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const fill = value as SkinButtonFill;
|
||||
return ["solid", "gradient"].includes(fill.mode) && Number.isInteger(fill.angle) && fill.angle >= 0 && fill.angle <= 360
|
||||
&& SKIN_BUTTON_COLOR_FIELDS.every(({ key }) => typeof fill[key] === "string" && HEX_COLOR.test(fill[key]));
|
||||
}
|
||||
|
||||
function normalizeButtonFill(value: unknown, skinID: string): SkinButtonFill {
|
||||
return isSkinButtonFill(value) ? { ...value } : { ...DEFAULT_BUTTON_GRADIENT, mode: skinID === "classic" ? "gradient" : "solid" };
|
||||
}
|
||||
|
||||
export function getSkinButtonAppearance(skin: SkinDefinition, mode: SkinThemeMode) {
|
||||
const fill = normalizeButtonFill(skin.tokens.buttons?.[mode], skin.id);
|
||||
const color = skin.tokens[mode];
|
||||
const gradient = (start: string, end: string) => `linear-gradient(${fill.angle}deg, ${start}, ${end})`;
|
||||
return fill.mode === "gradient" ? {
|
||||
background: gradient(fill.start, fill.end), hover: gradient(fill.hoverStart, fill.hoverEnd),
|
||||
active: gradient(fill.activeStart, fill.activeEnd), foreground: fill.foreground,
|
||||
} : { background: color.primary, hover: color.primaryHover, active: color.primaryActive, foreground: color.primaryForeground };
|
||||
}
|
||||
|
||||
function skinButtonCSSVariables(skin: SkinDefinition, mode: SkinThemeMode) {
|
||||
const button = getSkinButtonAppearance(skin, mode);
|
||||
return { "--button-primary-bg": button.background, "--button-primary-hover-bg": button.hover,
|
||||
"--button-primary-active-bg": button.active, "--button-primary-fg": button.foreground };
|
||||
}
|
||||
|
||||
export function skinCSSVariables(skin: SkinDefinition, mode: SkinThemeMode): Record<string, string> {
|
||||
const color = skin.tokens[mode];
|
||||
const component = skin.tokens.components;
|
||||
@@ -777,7 +837,8 @@ function skinShadowVariables(style: SkinShadowStyle, mode: SkinThemeMode) {
|
||||
}
|
||||
|
||||
function cloneSkinTokens(tokens: SkinTokens): SkinTokens {
|
||||
return { light: { ...tokens.light }, dark: { ...tokens.dark }, components: { ...tokens.components } };
|
||||
return { light: { ...tokens.light }, dark: { ...tokens.dark }, components: { ...tokens.components },
|
||||
buttons: { light: { ...tokens.buttons.light }, dark: { ...tokens.buttons.dark } } };
|
||||
}
|
||||
|
||||
function isSkinMode(value: unknown): value is SkinModeTokens {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models";
|
||||
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog } from "@/services/api/logical-models";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
|
||||
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
|
||||
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
|
||||
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { imageSizeConfigWithPresets } from "@/lib/image-size-presets";
|
||||
@@ -90,12 +90,8 @@ export async function refreshSystemChannels() {
|
||||
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
|
||||
}
|
||||
|
||||
// 模型目录来源决定数据形状;这里统一做运行时收口,避免畸形响应被当成“空目录”写入用户配置。
|
||||
// 目录仅接受系统渠道模型,避免畸形响应被当成空目录写入配置。
|
||||
function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] {
|
||||
if (catalog.source === "frontend") {
|
||||
if (!Array.isArray(catalog.models)) throw new Error("模型目录响应缺少前台模型列表");
|
||||
return managedModelChannels(catalog.models);
|
||||
}
|
||||
if (catalog.source === "system") {
|
||||
if (!Array.isArray(catalog.channels)) throw new Error("模型目录响应缺少系统渠道列表");
|
||||
return systemChannelModelChannels(catalog.channels);
|
||||
@@ -103,42 +99,6 @@ function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] {
|
||||
throw new Error("模型目录响应来源无效");
|
||||
}
|
||||
|
||||
function managedModelChannels(models: PublicLogicalModel[]) {
|
||||
const availableModels = models.filter((item) => item.available);
|
||||
if (!availableModels.length) return [];
|
||||
const managed: ModelChannel = {
|
||||
id: PUBLIC_MODEL_CATALOG_ID,
|
||||
name: "平台模型",
|
||||
baseUrl: "/api",
|
||||
apiKey: "system",
|
||||
apiFormat: "openai",
|
||||
scope: "system",
|
||||
enabled: true,
|
||||
models: availableModels.map((item) => item.id),
|
||||
modelAliases: Object.fromEntries(availableModels.flatMap((item) => (item.legacyModelIds || []).map((legacyID) => [legacyID, item.id]))),
|
||||
modelCosts: availableModels.map((item) => ({
|
||||
model: item.id,
|
||||
displayName: item.name,
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
capability: item.capability,
|
||||
pricePolicy: item.pricePolicy,
|
||||
billingMode: item.billingMode,
|
||||
unitPriceMicrocredits: item.unitPriceMicrocredits,
|
||||
inputTokenPriceMicrocredits: item.inputPriceMicrocredits,
|
||||
outputTokenPriceMicrocredits: item.outputPriceMicrocredits,
|
||||
cachedTokenPriceMicrocredits: item.cachedPriceMicrocredits,
|
||||
capabilityConfig: projectLogicalCapability(item.capabilitySpec, item.defaultOptions),
|
||||
logicalModelId: item.id,
|
||||
logicalCapabilitySpec: item.capabilitySpec,
|
||||
logicalCapabilityProfiles: item.capabilityProfiles,
|
||||
logicalPriceTiers: item.priceTiers,
|
||||
defaultOptions: item.defaultOptions,
|
||||
})),
|
||||
};
|
||||
return [managed];
|
||||
}
|
||||
|
||||
// 系统渠道模型转换为前端配置格式
|
||||
export function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] {
|
||||
return channels.map((channel) => {
|
||||
@@ -149,7 +109,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
|
||||
sortOrder: channel.sortOrder,
|
||||
// 系统渠道必须走带渠道 ID 的站内代理;/api 只是业务 API 根路径,
|
||||
// 不能作为模型请求的运行时 Base URL 传给 channelRequest。
|
||||
baseUrl: `/api/ai/system/${channel.id}`,
|
||||
baseUrl: `/api/${channel.id}`,
|
||||
apiKey: "system",
|
||||
apiFormat: "openai",
|
||||
scope: "system" as const,
|
||||
@@ -178,6 +138,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
|
||||
return {
|
||||
model: model.modelKey,
|
||||
displayName: model.displayName,
|
||||
channelLabel: model.channelLabel,
|
||||
description: model.description || "",
|
||||
icon: model.icon || "",
|
||||
capability: model.capability as ModelCapability,
|
||||
|
||||
Reference in New Issue
Block a user