feat(*): 创作工作台 - 完善画布生成、模型定价与创建流程

This commit is contained in:
ddcat666
2026-10-10 04:40:20 +08:00
parent 508cad2b4e
commit 611949d6cc
39 changed files with 735 additions and 242 deletions

View File

@@ -538,9 +538,9 @@ export function AgentReasoningFeed({ items, theme }: { items: CloudAgentChatMess
/**
* Agent SSE events contain text chunks. Keep the full text in the message
* state, but reveal one code point at a time so a chunk never appears as a
* whole block. The loop continues briefly after the stream ends to drain any
* text that was buffered by the network.
* state, but reveal it in small batches. A fixed one-code-point delay lets a
* fast stream build an ever-growing queue, so the batch grows with the
* backlog and eventually catches up to the latest text in one render.
*/
function useTypewriterText(targetText: string, shouldAnimate: boolean) {
const targetRef = useRef(targetText);
@@ -568,16 +568,29 @@ function useTypewriterText(targetText: string, shouldAnimate: boolean) {
}
const currentCharacters = Array.from(visibleRef.current);
if (currentCharacters.length >= targetCharacters.length) {
const pendingCharacters = targetCharacters.length - currentCharacters.length;
if (pendingCharacters <= 0) {
runningRef.current = false;
timerRef.current = null;
return;
}
const nextText = targetCharacters.slice(0, currentCharacters.length + 1).join("");
// Normal output remains visibly animated. Once stream chunks start
// outrunning the renderer, increase the batch instead of queuing
// every code point behind a fixed 16ms delay.
const revealCount = pendingCharacters > 384
? pendingCharacters
: pendingCharacters > 192
? 32
: pendingCharacters > 96
? 16
: pendingCharacters > 24
? 8
: 3;
const nextText = targetCharacters.slice(0, currentCharacters.length + revealCount).join("");
visibleRef.current = nextText;
setVisibleText(nextText);
timerRef.current = window.setTimeout(step, 16);
timerRef.current = window.setTimeout(step, 12);
};
step();

View File

@@ -52,6 +52,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
import { DEFAULT_CANVAS_AGENT_SKILL_ID, ensureDefaultCanvasAgentSkill } from "@/services/skill-defaults";
import { AGENT_SCENE_DEFS, AgentChatComposer, parseCloudAgentFormAnswer, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, type AgentSceneBucket, type CloudAgentChatMessage } from "./canvas-cloud-agent-chat-ui";
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser";
@@ -485,21 +486,52 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
.then(async (document) => {
if (!active) return;
const current = document.conversations.find((conversation) => conversation.id === document.activeId) || document.conversations[0];
let initialSkillIds = current?.skillIds || [];
if (!document.conversations.length) {
try {
const defaults = await ensureDefaultCanvasAgentSkill();
if (!active) return;
setSkills(defaults.skills);
initialSkillIds = defaults.skills.some((skill) => skill.skillId === DEFAULT_CANVAS_AGENT_SKILL_ID && skill.isAdded)
? [DEFAULT_CANVAS_AGENT_SKILL_ID]
: [];
} catch (cause) {
if (active) setMessages((currentMessages) => appendAgentError(currentMessages, "default-skill-error", cause, "默认短剧技能未能安装,已保留空技能选择"));
}
}
setConversations(document.conversations);
if (current) {
setActiveConversationId(current.id);
setMessages(current.messages);
setRun(current.run);
setPermissionMode(current.permissionMode);
setSelectedSkillIds(current.skillIds || []);
setSelectedSkillIds(initialSkillIds);
if (current.model) setModel(current.model);
const pending = await loadCloudAgentPendingSubmission(canvasId, current.id);
if (!active) return;
pendingSubmission.current = pending;
if (pending?.request) setPrompt(pending.displayText || pending.request.prompt);
} else {
setActiveConversationId(nanoid());
const initialConversationId = nanoid();
const now = new Date().toISOString();
const initialConversation: CloudAgentConversation = {
id: initialConversationId,
title: "新对话",
messages: [],
run: null,
model: selectedModel || undefined,
permissionMode,
skillIds: initialSkillIds,
createdAt: now,
updatedAt: now,
};
setActiveConversationId(initialConversationId);
setConversations([initialConversation]);
setSelectedSkillIds(initialSkillIds);
pendingSubmission.current = null;
void saveCloudAgentConversations(canvasId, initialConversationId, [initialConversation]).catch((cause) => {
if (active) setMessages((currentMessages) => appendAgentError(currentMessages, "initial-conversation-save-error", cause, "新对话初始化保存失败,请重试后再发送"));
});
}
setPendingHydrated(true);
})
@@ -516,7 +548,16 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
}, [canvasId, userId]);
useEffect(() => {
if (!historyHydrated || (!messages.length && !run)) return;
if (!historyHydrated) return;
if (!messages.length && !run) {
setConversations((current) => {
const existing = current.find((conversation) => conversation.id === activeConversationId);
if (!existing || existing.skillIds?.join("\0") === selectedSkillIds.join("\0")) return current;
const now = new Date().toISOString();
return current.map((conversation) => conversation.id === activeConversationId ? { ...conversation, skillIds: selectedSkillIds, updatedAt: now } : conversation);
});
return;
}
const now = new Date().toISOString();
setConversations((current) => {
const existing = current.find((conversation) => conversation.id === activeConversationId);

View File

@@ -18,6 +18,7 @@ import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import type { ModelReferenceLimits } from "@/lib/model-selection";
import type { Asset } from "@/stores/use-asset-store";
import { resourceIdFromStorageKey } from "@/services/api/resources";
export type CharacterGenerationReference = {
nodeId: string;
@@ -444,18 +445,28 @@ export function buildNodeResponseMessages(context: NodeGenerationContext): AiTex
];
}
export async function hydrateNodeGenerationContext(context: NodeGenerationContext, projectId: string, domainProjectId?: string, mode?: CanvasGenerationMode, includeCharacterVoiceSamples = false, includeCharacterPrompt = true, referenceLimits?: ModelReferenceLimits) {
export async function hydrateNodeGenerationContext(
context: NodeGenerationContext,
projectId: string,
domainProjectId?: string,
mode?: CanvasGenerationMode,
includeCharacterVoiceSamples = false,
includeCharacterPrompt = true,
referenceLimits?: ModelReferenceLimits,
materializeStoredMedia = true,
) {
const { imageToDataUrl } = await import("@/services/image-storage");
let referenceImages = await Promise.all(
context.referenceImages.map(async (image) => {
if (image.source?.kind === "drawing") return resolveCanvasDrawingReference(projectId, image);
if (image.source?.kind === "colorgrade") return resolveCanvasColorGradeReference(image);
if (!materializeStoredMedia && resourceIdFromStorageKey(image.storageKey)) return image;
return { ...image, dataUrl: await imageToDataUrl(image) };
}),
);
if (!context.characterReferences.length) return { ...context, referenceImages };
const { getCharacter } = await import("@/services/api/projects");
const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
const { getResource, resourceFileUrl, resourceIdFromStorageKey: getResourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
const details = await Promise.all(context.characterReferences.map((reference) => getCharacter(reference.assetId)));
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
const selectedEntries = details.flatMap((detail, index) => {
@@ -484,7 +495,9 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
dataUrl: "",
storageKey: resourceStorageKey(entry.representation.resourceId),
} satisfies ReferenceImage));
const hydratedCharacterImages = await Promise.all(characterImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) })));
const hydratedCharacterImages = materializeStoredMedia
? await Promise.all(characterImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) })))
: characterImages;
const primaryByNodeId = new Map<string, ReferenceImage>();
selectedEntries.forEach((entry, index) => {
const image = hydratedCharacterImages[index];
@@ -545,7 +558,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
instructions: [language && `语言与口音:${language}`, voiceAge && `声音年龄感:${voiceAge}`, timbre && `音色气质:${timbre}`, deliveryInstructions].filter(Boolean).join(";"),
}];
});
const usedAudioResourceIds = new Set(context.referenceAudios.map((audio) => resourceIdFromStorageKey(audio.storageKey)).filter(Boolean));
const usedAudioResourceIds = new Set(context.referenceAudios.map((audio) => getResourceIdFromStorageKey(audio.storageKey)).filter(Boolean));
const voiceSamples: ResolvedCharacterVoice[] = [];
// 视频模型接收声音样本;独立配音任务仍通过 voiceKey 选音色,不能把两种协议混用。
if (mode === "video" && includeCharacterVoiceSamples) {
@@ -570,7 +583,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
}));
const referenceAudios = [...context.referenceAudios, ...characterVoiceAudios];
const voiceBlocks = mode === "video" ? resolvedCharacterVoices.flatMap((voice) => {
const sampleIndex = voice.sampleResourceId ? referenceAudios.findIndex((audio) => resourceIdFromStorageKey(audio.storageKey) === voice.sampleResourceId) : -1;
const sampleIndex = voice.sampleResourceId ? referenceAudios.findIndex((audio) => getResourceIdFromStorageKey(audio.storageKey) === voice.sampleResourceId) : -1;
if (sampleIndex < 0 && !includeCharacterPrompt) return [];
return [[
includeCharacterPrompt ? compileResolvedVoicePrompt(voice) : `【角色声音:${voice.characterName}】`,