feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续
This commit is contained in:
43
web/src/services/api/creation-runs.ts
Normal file
43
web/src/services/api/creation-runs.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { http } from "./request";
|
||||
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CreateTaskInput, GenerationTask } from "./task-center";
|
||||
|
||||
export type CreationStatus = "idle" | "running" | "waiting_answer" | "waiting_proposal" | "waiting_canvas" | "waiting_payment" | "waiting_task" | "paused" | "completed" | "cancelled";
|
||||
export type CreationGuard = { executionEpoch: number; owner: string };
|
||||
export type CreationRun = {
|
||||
id: string; userId: string; canvasId?: string; revision: number;
|
||||
executionEpoch: number; executionOwner: string; leaseExpiresAt?: string;
|
||||
status: CreationStatus; state: Record<string, unknown>;
|
||||
approvedProposalVersion?: number; approvedProposalHash?: string;
|
||||
createdAt: string; updatedAt: string;
|
||||
};
|
||||
export type CreationSubmission = {
|
||||
id: string; runId: string; itemKey: string; requestHash: string;
|
||||
quote: { model: string; billingMode: string; quantity: number; amountMicrocredits: number; estimated: boolean; expiresAt: string; quoteHash: string; options?: Record<string, unknown> };
|
||||
approvedAt?: string; revokedAt?: string; taskId?: string;
|
||||
};
|
||||
export type CreationRunDetail = { run: CreationRun; submissions: CreationSubmission[] };
|
||||
|
||||
const path = (id: string) => `/creation-runs/${encodeURIComponent(id)}`;
|
||||
export const creationRuns = {
|
||||
create: (input: { clientKey: string; canvasId?: string; state: Record<string, unknown> }, signal?: AbortSignal) => http.post<CreationRunDetail>("/creation-runs", input, { signal }),
|
||||
get: (id: string, signal?: AbortSignal) => http.get<CreationRunDetail>(path(id), { signal }),
|
||||
list: (signal?: AbortSignal) => http.get<{ runs: CreationRun[] }>("/creation-runs", { signal }),
|
||||
save: (id: string, input: CreationGuard & { revision: number; state: Record<string, unknown>; status: CreationStatus }, signal?: AbortSignal) => http.patch<CreationRun>(path(id), input, { signal }),
|
||||
claim: (id: string, input: { expectedEpoch: number; owner: string }, signal?: AbortSignal) => http.post<CreationRun>(`${path(id)}/claim`, input, { signal }),
|
||||
heartbeat: (id: string, guard: CreationGuard, signal?: AbortSignal) => http.post<{ leaseExpiresAt: string }>(`${path(id)}/heartbeat`, guard, { signal }),
|
||||
release: (id: string, guard: CreationGuard) => http.post<{ released: boolean }>(`${path(id)}/release`, guard),
|
||||
approveProposal: (id: string, input: CreationGuard & { revision: number; proposalVersion: number; proposal: unknown; ops: CanvasAgentOp[] }, signal?: AbortSignal) => http.post<CreationRun>(`${path(id)}/proposal-approve`, input, { signal }),
|
||||
invalidateProposal: (id: string, input: CreationGuard & { revision: number }, signal?: AbortSignal) => http.post<CreationRun>(`${path(id)}/proposal-invalidate`, input, { signal }),
|
||||
canvas: (id: string, guard: CreationGuard, signal?: AbortSignal) => http.post<{ run: CreationRun; canvasId: string }>(`${path(id)}/canvas`, guard, { signal }),
|
||||
prepare: (id: string, input: CreationGuard & { itemKey: string; proposalVersion?: number; request: CreateTaskInput }, signal?: AbortSignal) => http.post<CreationSubmission>(`${path(id)}/submissions/prepare`, input, { signal }),
|
||||
approve: (id: string, input: CreationGuard & { submissionIds: string[] }, signal?: AbortSignal) => http.post<{ submissions: CreationSubmission[] }>(`${path(id)}/submissions/approve`, input, { signal }),
|
||||
refreshQuote: (id: string, input: CreationGuard & { submissionId: string }, signal?: AbortSignal) => http.post<CreationSubmission>(`${path(id)}/submissions/refresh`, input, { signal }),
|
||||
execute: (id: string, input: CreationGuard & { submissionId: string }, signal?: AbortSignal) => http.post<GenerationTask>(`${path(id)}/execute`, input, { signal }).then((task) => {
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
return task;
|
||||
}),
|
||||
canvasSnapshot: (id: string, signal?: AbortSignal) => http.get<{ document: CanvasProject; snapshotHash: string }>(`${path(id)}/canvas-snapshot`, { signal }),
|
||||
commitCanvas: (id: string, input: CreationGuard & { expectedSnapshotHash: string; document: CanvasProject }, signal?: AbortSignal) => http.post<{ snapshotHash: string }>(`${path(id)}/canvas-commit`, input, { signal }),
|
||||
};;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
|
||||
import { createGenerationTask, waitForGenerationTask, type GenerationTask, type CreateTaskInput } from "@/services/api/task-center";
|
||||
import { LOCAL_DREAMINA_WAIT_STOPPED_CODE, LocalDreaminaGenerationClientError, runLocalDreaminaGenerationTask, type LocalDreaminaGenerationInput, type LocalDreaminaGenerationTask } from "@/services/local-dreamina-generation";
|
||||
import { isLocalDreaminaBackgroundTask, localDreaminaTaskId, projectLocalDreaminaTask, stripLocalDreaminaTaskPrefix } from "@/services/local-dreamina-task-projection";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
@@ -13,6 +13,7 @@ import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-st
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
import { buildBackendToolRequests, type ResponseFunctionTool, type ResponseInputMessage, type ToolChoice, type ToolResponseResult } from "@/services/api/image";
|
||||
import { assertAgentExchangeBudget } from "@/lib/canvas/canvas-agent-context-budget";
|
||||
|
||||
export { logicalModelIDForConfig };
|
||||
|
||||
@@ -134,7 +135,7 @@ export async function submitBackendGenerationTask(
|
||||
return createBackendGenerationTask(options, prepared, dependencies);
|
||||
}
|
||||
|
||||
export async function runBackendToolGenerationTask(options: {
|
||||
type BackendToolGenerationOptions = {
|
||||
prompt: string;
|
||||
config: AiConfig;
|
||||
messages: ResponseInputMessage[];
|
||||
@@ -142,12 +143,28 @@ export async function runBackendToolGenerationTask(options: {
|
||||
toolChoice: ToolChoice;
|
||||
signal?: AbortSignal;
|
||||
onDelta?: (text: string) => void;
|
||||
}): Promise<ToolResponseResult> {
|
||||
onTaskCreated?: (task: GenerationTask) => void;
|
||||
metadata?: { source: string; nodeId?: string; runId?: string; stage?: string };
|
||||
};
|
||||
|
||||
// 报价和执行复用完全相同的任务协议,准备阶段不提交模型任务。
|
||||
export function prepareBackendToolGenerationTask(options: BackendToolGenerationOptions): CreateTaskInput {
|
||||
throwIfAborted(options.signal);
|
||||
assertAgentExchangeBudget(options.messages, options.tools, options.config.systemPrompt || "");
|
||||
const imageKeys = new Set<string>();
|
||||
for (const message of options.messages) {
|
||||
if ("type" in message || message.role === "tool" || !Array.isArray(message.content)) continue;
|
||||
for (const part of message.content) {
|
||||
if (part.type !== "image_url") continue;
|
||||
const key = part.image_url.url;
|
||||
if (!resourceIdFromStorageKey(key)) throw new Error("看图素材尚未保存为资源,请让助手重新读取图片后继续。");
|
||||
imageKeys.add(key);
|
||||
}
|
||||
}
|
||||
const logicalModelId = logicalModelIDForConfig(options.config);
|
||||
const requestConfig = resolveModelRequestConfig(options.config, options.config.model);
|
||||
if (!logicalModelId && !requestConfig.channelId && !requestConfig.interfaceType) throw new Error("当前模型未选择可用请求协议");
|
||||
const task = await createGenerationTask({
|
||||
const task: CreateTaskInput = {
|
||||
type: "canvas_text",
|
||||
operation: "text",
|
||||
prompt: options.prompt,
|
||||
@@ -156,11 +173,21 @@ export async function runBackendToolGenerationTask(options: {
|
||||
input: {
|
||||
mode: "text",
|
||||
prompt: options.prompt,
|
||||
config: backendProviderConfig(options.config),
|
||||
config: backendProviderConfig(options.config, "text"),
|
||||
agentRequests: buildBackendToolRequests(options.messages, options.tools, options.toolChoice, options.config),
|
||||
metadata: { source: "canvas-online-agent" },
|
||||
referenceImages: [...imageKeys].map((storageKey) => ({ storageKey })),
|
||||
metadata: options.metadata || { source: "canvas-online-agent" },
|
||||
},
|
||||
});
|
||||
};
|
||||
if (new Blob([JSON.stringify(task)]).size > 15 * 1024 * 1024) {
|
||||
throw new Error("本次图片或对话内容过多,请减少参考图片,或新建会话后继续。已有作品会保留。");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function runBackendToolGenerationTask(options: BackendToolGenerationOptions): Promise<ToolResponseResult> {
|
||||
const task = await createGenerationTask(prepareBackendToolGenerationTask(options));
|
||||
options.onTaskCreated?.(task);
|
||||
const completed = await waitForGenerationTask(task.id, { signal: options.signal, initialTask: task, onTextDelta: options.onDelta });
|
||||
const result = parseBackendGenerationResult(completed);
|
||||
return {
|
||||
@@ -425,11 +452,27 @@ async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions
|
||||
}
|
||||
|
||||
async function createBackendGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
|
||||
const { projectId, mode, prompt, config, metadata, onTaskUpdate } = options;
|
||||
const task = await dependencies.createTask(backendGenerationTaskInput(options, prepared));
|
||||
options.onTaskUpdate?.(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function prepareBackendGenerationTask(options: BackendGenerationTaskOptions): Promise<CreateTaskInput> {
|
||||
throwIfAborted(options.signal);
|
||||
if (usesLocalDreamina(options.config)) throw new Error("智能创作的在线授权暂不支持本机即梦,请使用现有本机入口");
|
||||
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
|
||||
assertBackendRuntimeConfigured(options.config, options.mode);
|
||||
const prepared = await prepareGenerationReferences(options);
|
||||
throwIfAborted(options.signal);
|
||||
return backendGenerationTaskInput(options, prepared);
|
||||
}
|
||||
|
||||
function backendGenerationTaskInput(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences): CreateTaskInput {
|
||||
const { projectId, mode, prompt, config, metadata } = options;
|
||||
const videoOperation = generationOperation(options);
|
||||
const workflow = resolveGenerationWorkflowExecution(config, mode);
|
||||
const logicalModelId = workflow ? "" : logicalModelIDForConfig(config);
|
||||
const task = await dependencies.createTask({
|
||||
return {
|
||||
...(projectId ? { projectId } : {}),
|
||||
type: `canvas_${mode}`,
|
||||
operation: mode === "video" ? videoOperation : mode,
|
||||
@@ -451,9 +494,7 @@ async function createBackendGenerationTask(options: BackendGenerationTaskOptions
|
||||
mask: prepared.mask,
|
||||
metadata: generationMetadata(config, metadata),
|
||||
},
|
||||
});
|
||||
onTaskUpdate?.(task);
|
||||
return task;
|
||||
};
|
||||
}
|
||||
|
||||
function generationMetadata(config: AiConfig, metadata?: Record<string, unknown>) {
|
||||
|
||||
@@ -33,10 +33,12 @@ export type ToolResponseResult = {
|
||||
export type ToolChoice = "auto" | "required" | { type: "function"; name: string };
|
||||
|
||||
export type BackendToolRequests = {
|
||||
responses: Record<string, unknown>;
|
||||
chatCompletion: Record<string, unknown>;
|
||||
claude?: Record<string, unknown>;
|
||||
gemini?: Record<string, unknown>;
|
||||
canonical: {
|
||||
messages: ResponseInputMessage[];
|
||||
tools: ResponseFunctionTool[];
|
||||
toolChoice: ToolChoice;
|
||||
systemPrompt: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ResponseMessageContent = AiTextMessage["content"] | string;
|
||||
|
||||
@@ -102,28 +102,14 @@ export function toChatCompletionToolChoice(toolChoice: ToolChoice) {
|
||||
}
|
||||
|
||||
export function buildBackendToolRequests(messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice, config?: AiConfig): BackendToolRequests {
|
||||
const requests: BackendToolRequests = {
|
||||
responses: {
|
||||
input: toResponseInput(messages),
|
||||
tools: tools.map(toResponseTool),
|
||||
tool_choice: toolChoice,
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
chatCompletion: {
|
||||
messages: toChatCompletionMessages(messages),
|
||||
return {
|
||||
canonical: {
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: toChatCompletionToolChoice(toolChoice),
|
||||
parallel_tool_calls: false,
|
||||
toolChoice,
|
||||
systemPrompt: config?.systemPrompt || "",
|
||||
},
|
||||
};
|
||||
if (config) {
|
||||
requests.claude = {
|
||||
...toClaudeBody(config, messages, tools),
|
||||
tool_choice: typeof toolChoice === "object" ? { type: "tool", name: toolChoice.name } : { type: toolChoice === "required" ? "any" : "auto" },
|
||||
};
|
||||
requests.gemini = toGeminiBody(config, messages, toGeminiToolOptions(tools, toolChoice));
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
|
||||
export function toGeminiBody(config: AiConfig, messages: ResponseInputMessage[], extra?: Record<string, unknown>) {
|
||||
|
||||
Reference in New Issue
Block a user