feat: 完成短剧项目空间与结构化资产创作闭环 (#38)
* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP * feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环 Refs #30 * fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置 * feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环 Refs #30 --------- Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
94
web/src/services/api/project-agent-tools.ts
Normal file
94
web/src/services/api/project-agent-tools.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
confirmProjectAssetCandidate,
|
||||
createProjectAssetVersion,
|
||||
createProjectAssetCandidates,
|
||||
getProject,
|
||||
linkProjectAsset,
|
||||
linkShotAsset,
|
||||
registerProjectTaskOutput,
|
||||
saveProjectShot,
|
||||
updateWorkflowStep,
|
||||
type ProjectDetail,
|
||||
type ShotAssetReference,
|
||||
} from "./projects";
|
||||
|
||||
export const projectAgentToolNames = [
|
||||
"project_get_context",
|
||||
"project_list_units",
|
||||
"project_extract_asset_candidates",
|
||||
"project_confirm_asset_candidate",
|
||||
"project_create_or_update_shots",
|
||||
"project_link_shot_asset",
|
||||
"project_start_workflow_step",
|
||||
"project_link_asset",
|
||||
"project_upsert_asset_version",
|
||||
"project_register_task_output",
|
||||
] as const;
|
||||
|
||||
export type ProjectAgentToolName = (typeof projectAgentToolNames)[number];
|
||||
|
||||
export function isProjectAgentToolName(value: string): value is ProjectAgentToolName {
|
||||
return projectAgentToolNames.includes(value as ProjectAgentToolName);
|
||||
}
|
||||
|
||||
export function isProjectAgentReadTool(value: string) {
|
||||
return value === "project_get_context" || value === "project_list_units";
|
||||
}
|
||||
|
||||
export async function runProjectAgentTool(name: ProjectAgentToolName, rawInput: Record<string, unknown>, fallbackProjectId?: string) {
|
||||
const projectId = String(rawInput.projectId || fallbackProjectId || "").trim();
|
||||
if (!projectId) throw new Error("当前画布没有关联短剧项目");
|
||||
if (name === "project_get_context") return getProject(projectId);
|
||||
if (name === "project_list_units") {
|
||||
const detail = await getProject(projectId);
|
||||
const kind = String(rawInput.kind || "").trim();
|
||||
const status = String(rawInput.status || "").trim();
|
||||
return { units: detail.units.filter((unit) => (!kind || unit.kind === kind) && (!status || unit.status === status)) };
|
||||
}
|
||||
if (name === "project_extract_asset_candidates") {
|
||||
const candidates = Array.isArray(rawInput.candidates) ? rawInput.candidates : [];
|
||||
return createProjectAssetCandidates(projectId, candidates.filter(isCandidateInput));
|
||||
}
|
||||
if (name === "project_confirm_asset_candidate") {
|
||||
return confirmProjectAssetCandidate(projectId, String(rawInput.candidateId || ""), String(rawInput.assetId || "") || undefined);
|
||||
}
|
||||
if (name === "project_create_or_update_shots") {
|
||||
const shots = Array.isArray(rawInput.shots) ? rawInput.shots : [];
|
||||
const result = [];
|
||||
for (const shot of shots) {
|
||||
if (!isShotInput(shot)) continue;
|
||||
result.push((await saveProjectShot(projectId, shot)).shot);
|
||||
}
|
||||
return { shots: result };
|
||||
}
|
||||
if (name === "project_link_shot_asset") {
|
||||
return linkShotAsset(projectId, String(rawInput.shotId || ""), { assetVersionId: String(rawInput.assetVersionId || ""), role: String(rawInput.role || "reference") as ShotAssetReference["role"] });
|
||||
}
|
||||
if (name === "project_start_workflow_step") {
|
||||
return updateWorkflowStep(projectId, String(rawInput.stepId || ""), { status: "running" });
|
||||
}
|
||||
if (name === "project_link_asset") {
|
||||
return linkProjectAsset(projectId, { assetId: String(rawInput.assetId || ""), category: String(rawInput.category || "other") });
|
||||
}
|
||||
if (name === "project_upsert_asset_version") {
|
||||
return createProjectAssetVersion(projectId, String(rawInput.assetId || ""), { prompt: String(rawInput.prompt || ""), definitionJson: typeof rawInput.definitionJson === "string" ? rawInput.definitionJson : undefined, note: String(rawInput.note || "") });
|
||||
}
|
||||
if (name === "project_register_task_output") {
|
||||
return registerProjectTaskOutput(projectId, String(rawInput.stepId || ""), { taskId: String(rawInput.taskId || ""), assetVersionId: String(rawInput.assetVersionId || "") || undefined, resourceId: String(rawInput.resourceId || "") || undefined, mediaType: String(rawInput.mediaType || "") || undefined, role: String(rawInput.role || "output"), metadataJson: typeof rawInput.metadataJson === "string" ? rawInput.metadataJson : undefined, outputJson: typeof rawInput.outputJson === "string" ? rawInput.outputJson : undefined });
|
||||
}
|
||||
throw new Error(`未知项目工具:${name}`);
|
||||
}
|
||||
|
||||
function isCandidateInput(value: unknown): value is { unitId?: string; shotId?: string; name: string; category: string; details?: Record<string, unknown> } {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
return typeof item.name === "string" && typeof item.category === "string";
|
||||
}
|
||||
|
||||
function isShotInput(value: unknown): value is { id?: string; unitId?: string; title: string; description?: string; position?: number; durationMs?: number; status?: string } {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
return typeof item.title === "string";
|
||||
}
|
||||
|
||||
export type ProjectAgentContext = ProjectDetail;
|
||||
312
web/src/services/api/projects.ts
Normal file
312
web/src/services/api/projects.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import axios from "axios";
|
||||
|
||||
import type { BackendEnvelope } from "@/services/api/task-center";
|
||||
|
||||
const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true });
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
aspectRatio: string;
|
||||
sourceType: string;
|
||||
description: string;
|
||||
stylePresetId: string;
|
||||
activeTaskLimit: number;
|
||||
status: "active" | "archived" | string;
|
||||
revision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ProjectCanvas = {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CanvasUnitLink = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
canvasId: string;
|
||||
unitId: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ProjectUnit = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
kind: "chapter" | "episode" | string;
|
||||
title: string;
|
||||
sourceText: string;
|
||||
status: "draft" | "ready" | "completed" | string;
|
||||
position: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ProjectAsset = {
|
||||
id: string;
|
||||
title: string;
|
||||
mediaType: string;
|
||||
category: string;
|
||||
status: string;
|
||||
primaryVersionId?: string;
|
||||
versionCount: number;
|
||||
usages: string[];
|
||||
updatedAt: string;
|
||||
character?: CharacterCardSummary;
|
||||
};
|
||||
|
||||
export type CharacterRepresentation = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
mediaType: string;
|
||||
role: "primary" | "front" | "side" | "back" | "turnaround_sheet" | "expression_sheet" | string;
|
||||
};
|
||||
|
||||
export type VoiceProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
voiceKey: string;
|
||||
language: string;
|
||||
timbre: string;
|
||||
sampleResourceId?: string;
|
||||
compatibleModels: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CharacterCardSummary = {
|
||||
versionId: string;
|
||||
version: number;
|
||||
definition: Record<string, unknown>;
|
||||
representations: CharacterRepresentation[];
|
||||
voice?: { profile: VoiceProfile; instructions: string };
|
||||
visualStatus: "missing" | "partial" | "ready" | string;
|
||||
voiceStatus: "missing" | "ready" | "unavailable" | string;
|
||||
};
|
||||
|
||||
export type ProjectCharacterDetail = {
|
||||
asset: ProjectAsset;
|
||||
character: CharacterCardSummary;
|
||||
};
|
||||
|
||||
export type ProjectAssetCandidate = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
unitId?: string;
|
||||
shotId?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: "pending_confirmation" | "confirmed" | "ignored" | string;
|
||||
detailsJson: string;
|
||||
resolvedAssetId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ProjectShot = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
unitId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
position: number;
|
||||
durationMs: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ShotAssetReference = {
|
||||
id: string;
|
||||
shotId: string;
|
||||
assetVersionId: string;
|
||||
role: "reference" | "start_frame" | "end_frame" | "keyframe" | "storyboard" | "output" | string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type WorkflowStep = {
|
||||
id: string;
|
||||
workflowInstanceId: string;
|
||||
stepKey: string;
|
||||
name: string;
|
||||
position: number;
|
||||
status: "pending" | "ready" | "running" | "review" | "completed" | "failed" | "skipped" | string;
|
||||
error?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ProjectWorkflow = {
|
||||
instance: { id: string; projectId: string; unitId?: string; scope: string; status: string; revision: number };
|
||||
steps: WorkflowStep[];
|
||||
};
|
||||
|
||||
export type ProjectSummary = {
|
||||
project: Project;
|
||||
canvasCount: number;
|
||||
assetCount: number;
|
||||
unitCount: number;
|
||||
completedUnitCount: number;
|
||||
};
|
||||
|
||||
export type ProjectDetail = {
|
||||
project: Project;
|
||||
units: ProjectUnit[];
|
||||
canvases: ProjectCanvas[];
|
||||
canvasUnitLinks: CanvasUnitLink[];
|
||||
assets: ProjectAsset[];
|
||||
workflows: ProjectWorkflow[];
|
||||
shots: ProjectShot[];
|
||||
shotReferences: ShotAssetReference[];
|
||||
assetCandidates: ProjectAssetCandidate[];
|
||||
};
|
||||
|
||||
async function request<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
try {
|
||||
const response = await promise;
|
||||
if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败");
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function listProjects() {
|
||||
return request<{ projects: ProjectSummary[] }>(api.get("/projects"));
|
||||
}
|
||||
|
||||
export function getProject(id: string) {
|
||||
return request<ProjectDetail>(api.get(`/projects/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function createProject(input: { name: string; type: string; aspectRatio: string; sourceType: string; description?: string; stylePresetId?: string; activeTaskLimit?: number }) {
|
||||
return request<{ project: Project }>(api.post("/projects", input));
|
||||
}
|
||||
|
||||
export function updateProject(projectId: string, input: Partial<Pick<Project, "name" | "type" | "aspectRatio" | "sourceType" | "description" | "stylePresetId" | "activeTaskLimit" | "status">>) {
|
||||
return request<{ project: Project }>(api.patch(`/projects/${encodeURIComponent(projectId)}`, input));
|
||||
}
|
||||
|
||||
export function deleteProject(projectId: string) {
|
||||
return request<{ id: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}`));
|
||||
}
|
||||
|
||||
export function createProjectUnit(projectId: string, input: { kind: string; title: string; sourceText?: string; position?: number }) {
|
||||
return request<{ unit: ProjectUnit }>(api.post(`/projects/${encodeURIComponent(projectId)}/units`, input));
|
||||
}
|
||||
|
||||
export function getProjectUnit(projectId: string, unitId: string) {
|
||||
return request<{ unit: ProjectUnit }>(api.get(`/projects/${encodeURIComponent(projectId)}/units/${encodeURIComponent(unitId)}`));
|
||||
}
|
||||
|
||||
export function importProjectUnits(projectId: string, units: Array<{ kind: string; title: string; sourceText?: string }>) {
|
||||
return request<{ units: ProjectUnit[] }>(api.post(`/projects/${encodeURIComponent(projectId)}/units/import`, { units }));
|
||||
}
|
||||
|
||||
export function reorderProjectUnits(projectId: string, unitIds: string[]) {
|
||||
return request<{ unitIds: string[] }>(api.patch(`/projects/${encodeURIComponent(projectId)}/units/reorder`, { unitIds }));
|
||||
}
|
||||
|
||||
export function updateProjectUnit(projectId: string, unitId: string, input: { title?: string; sourceText: string; status?: ProjectUnit["status"] }) {
|
||||
return request<{ unit: ProjectUnit }>(api.patch(`/projects/${encodeURIComponent(projectId)}/units/${encodeURIComponent(unitId)}`, input));
|
||||
}
|
||||
|
||||
export function deleteProjectUnit(projectId: string, unitId: string) {
|
||||
return request<{ id: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/units/${encodeURIComponent(unitId)}`));
|
||||
}
|
||||
|
||||
export function linkCanvasUnit(projectId: string, input: { canvasId: string; unitId: string; role?: string }) {
|
||||
return request<{ link: { id: string; projectId: string; canvasId: string; unitId: string; role: string } }>(api.post(`/projects/${encodeURIComponent(projectId)}/canvas-links`, input));
|
||||
}
|
||||
|
||||
export function unlinkCanvasUnit(projectId: string, canvasId: string, unitId: string) {
|
||||
return request<{ canvasId: string; unitId: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/canvas-links/${encodeURIComponent(canvasId)}/units/${encodeURIComponent(unitId)}`));
|
||||
}
|
||||
|
||||
export function unlinkCanvasProject(projectId: string, canvasId: string) {
|
||||
return request<{ canvasId: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/canvases/${encodeURIComponent(canvasId)}`));
|
||||
}
|
||||
|
||||
export function linkProjectAsset(projectId: string, input: { assetId: string; category: string }) {
|
||||
return request<{ asset: ProjectAsset }>(api.post(`/projects/${encodeURIComponent(projectId)}/assets`, input));
|
||||
}
|
||||
|
||||
export function unlinkProjectAsset(projectId: string, assetId: string) {
|
||||
return request<{ id: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`));
|
||||
}
|
||||
|
||||
export function updateProjectAssetCategory(projectId: string, assetId: string, category: string) {
|
||||
return request<{ asset: ProjectAsset }>(api.patch(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`, { category }));
|
||||
}
|
||||
|
||||
export function createProjectAssetVersion(projectId: string, assetId: string, input: { prompt?: string; definitionJson?: string; note?: string }) {
|
||||
return request<{ version: { id: string; assetId: string; version: number; status: string } }>(api.post(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}/versions`, input));
|
||||
}
|
||||
|
||||
export function listVoiceProfiles() {
|
||||
return request<{ profiles: VoiceProfile[] }>(api.get("/voice-profiles"));
|
||||
}
|
||||
|
||||
export function createProjectCharacter(projectId: string, input: { name: string; definition?: Record<string, unknown> }) {
|
||||
return request<ProjectCharacterDetail>(api.post(`/projects/${encodeURIComponent(projectId)}/characters`, input));
|
||||
}
|
||||
|
||||
export function getProjectCharacter(projectId: string, assetId: string) {
|
||||
return request<ProjectCharacterDetail>(api.get(`/projects/${encodeURIComponent(projectId)}/characters/${encodeURIComponent(assetId)}`));
|
||||
}
|
||||
|
||||
export function updateProjectCharacter(projectId: string, assetId: string, input: { name: string; definition: Record<string, unknown> }) {
|
||||
return request<ProjectCharacterDetail>(api.patch(`/projects/${encodeURIComponent(projectId)}/characters/${encodeURIComponent(assetId)}`, input));
|
||||
}
|
||||
|
||||
export function replaceProjectCharacterRepresentations(projectId: string, assetId: string, representations: Array<{ role: string; resourceId: string; metadata?: Record<string, unknown> }>) {
|
||||
return request<ProjectCharacterDetail>(api.put(`/projects/${encodeURIComponent(projectId)}/characters/${encodeURIComponent(assetId)}/representations`, { representations }));
|
||||
}
|
||||
|
||||
export function bindProjectCharacterVoice(projectId: string, assetId: string, input: { voiceProfileId: string; instructions?: string }) {
|
||||
return request<ProjectCharacterDetail>(api.put(`/projects/${encodeURIComponent(projectId)}/characters/${encodeURIComponent(assetId)}/voice`, input));
|
||||
}
|
||||
|
||||
export function unbindProjectCharacterVoice(projectId: string, assetId: string) {
|
||||
return request<ProjectCharacterDetail>(api.delete(`/projects/${encodeURIComponent(projectId)}/characters/${encodeURIComponent(assetId)}/voice`));
|
||||
}
|
||||
|
||||
export function createUnitWorkflow(projectId: string, unitId: string) {
|
||||
return request<{ workflow: ProjectWorkflow }>(api.post(`/projects/${encodeURIComponent(projectId)}/workflows`, { unitId }));
|
||||
}
|
||||
|
||||
export function saveProjectShot(projectId: string, input: { id?: string; unitId?: string; title: string; description?: string; position?: number; durationMs?: number; status?: string }) {
|
||||
return request<{ shot: ProjectShot }>(api.post(`/projects/${encodeURIComponent(projectId)}/shots`, input));
|
||||
}
|
||||
|
||||
export function replaceProjectUnitShots(projectId: string, unitId: string, shots: Array<{ title: string; description: string; durationMs: number }>) {
|
||||
return request<{ shots: ProjectShot[] }>(api.put(`/projects/${encodeURIComponent(projectId)}/units/${encodeURIComponent(unitId)}/shots`, { shots }));
|
||||
}
|
||||
|
||||
export function linkShotAsset(projectId: string, shotId: string, input: { assetVersionId: string; role: ShotAssetReference["role"] }) {
|
||||
return request<{ reference: ShotAssetReference }>(api.post(`/projects/${encodeURIComponent(projectId)}/shots/${encodeURIComponent(shotId)}/assets`, input));
|
||||
}
|
||||
|
||||
export function createProjectAssetCandidates(projectId: string, candidates: Array<{ unitId?: string; shotId?: string; name: string; category: string; details?: Record<string, unknown> }>) {
|
||||
return request<{ candidates: ProjectAssetCandidate[] }>(api.post(`/projects/${encodeURIComponent(projectId)}/asset-candidates`, { candidates }));
|
||||
}
|
||||
|
||||
export function confirmProjectAssetCandidate(projectId: string, candidateId: string, assetId?: string) {
|
||||
return request<{ asset: ProjectAsset }>(api.post(`/projects/${encodeURIComponent(projectId)}/asset-candidates/${encodeURIComponent(candidateId)}/confirm`, { assetId: assetId || "" }));
|
||||
}
|
||||
|
||||
export function updateWorkflowStep(projectId: string, stepId: string, input: { status: string; outputJson?: string; error?: string }) {
|
||||
return request<{ step: WorkflowStep }>(api.patch(`/projects/${encodeURIComponent(projectId)}/workflow-steps/${encodeURIComponent(stepId)}`, input));
|
||||
}
|
||||
|
||||
export function registerProjectTaskOutput(projectId: string, stepId: string, input: { taskId: string; assetVersionId?: string; resourceId?: string; mediaType?: string; role?: string; metadataJson?: string; outputJson?: string }) {
|
||||
return request<{ step: WorkflowStep }>(api.post(`/projects/${encodeURIComponent(projectId)}/workflow-steps/${encodeURIComponent(stepId)}/task-output`, input));
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export type GenerationTask = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
errorCode?: string;
|
||||
previewUrl?: string;
|
||||
previewKind?: "image" | "video";
|
||||
inputJson?: string;
|
||||
resultJson?: string;
|
||||
error?: string;
|
||||
|
||||
@@ -65,7 +65,7 @@ export type ChannelModel = {
|
||||
modelKey: string;
|
||||
displayName: string;
|
||||
capability: "text" | "image" | "video" | "audio";
|
||||
billingMode: "fixed_request";
|
||||
billingMode: "fixed_request" | "per_second";
|
||||
unitPriceMicrocredits: number;
|
||||
priceConfigured: boolean;
|
||||
enabled: boolean;
|
||||
@@ -160,6 +160,10 @@ export type BillingOrder = {
|
||||
model: string;
|
||||
capability: string;
|
||||
scene: string;
|
||||
billingMode: "fixed_request" | "per_second";
|
||||
unitPriceMicrocredits: number;
|
||||
multiplierBasisPoints: number;
|
||||
quantity: number;
|
||||
amountMicrocredits: number;
|
||||
status: "reserved" | "running" | "settled" | "refunded" | "uncertain";
|
||||
providerRequestId?: string;
|
||||
@@ -231,8 +235,8 @@ export function updateAdminChannelModel(channelId: string, id: string, input: Om
|
||||
return request<{ model: ChannelModel }>(api.patch(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
export function disableAdminChannelModel(channelId: string, id: string) {
|
||||
return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`));
|
||||
export function deleteAdminChannelModel(channelId: string, id: string) {
|
||||
return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export type AdminFinanceListParams = { keyword?: string; status?: string; validity?: string; page?: number; limit?: number };
|
||||
|
||||
Reference in New Issue
Block a user