feat: publish Agent Studio integrations and setup guide
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
ImageVideoService,
|
||||
CmsConnection,
|
||||
ApprovalBroker,
|
||||
AttachmentStore,
|
||||
EventStore,
|
||||
@@ -18,6 +20,10 @@ import { registerEventRoute } from "./routes/events.js";
|
||||
import { registerModelRoutes } from "./routes/models.js";
|
||||
import { registerSessionRoutes } from "./routes/sessions.js";
|
||||
import { registerSkillsRoute } from "./routes/skills.js";
|
||||
import { registerReportRoutes } from "./routes/reports.js";
|
||||
import { registerCmsRoutes } from "./routes/cms.js";
|
||||
import { registerEditorRoutes } from "./routes/editor.js";
|
||||
import { registerImageVideoRoutes } from "./routes/image-video.js";
|
||||
import { SseRegistry, type SseRegistryPort } from "./sse/sse-registry.js";
|
||||
|
||||
export type ApiSession = SessionRecord & {
|
||||
@@ -27,6 +33,8 @@ export type ApiSession = SessionRecord & {
|
||||
};
|
||||
|
||||
export type AppDependencies = {
|
||||
imageVideo?: ImageVideoService;
|
||||
imageVideoPorts?: number[];
|
||||
sessionRegistry: {
|
||||
create(): Promise<ApiSession>;
|
||||
get(sessionId: string): ApiSession | undefined;
|
||||
@@ -38,6 +46,10 @@ export type AppDependencies = {
|
||||
attachmentStore: Pick<AttachmentStore, "add" | "remove">;
|
||||
approvalBroker: Pick<ApprovalBroker, "approve" | "deny">;
|
||||
eventStore?: EventStore;
|
||||
readReport?: (sessionId: string, reportId: string, format: string) => Promise<{ bytes: Buffer; contentType: string }>;
|
||||
readEditorFrame?: (sessionId: string, jobId: string, frameId: string) => Promise<Buffer>;
|
||||
editorForSession?: (sessionId:string)=>{workflowView:(jobId:string)=>unknown;review:(jobId:string,stage:"selection"|"plan",value:unknown)=>Promise<unknown>;video:(jobId:string,kind:"sources"|"plans",id:string)=>Promise<{path:string;size:number}>};
|
||||
cmsForSession?: (sessionId: string) => CmsConnection;
|
||||
sseRegistry?: SseRegistryPort;
|
||||
testModelConnection?: (
|
||||
resolved: ResolvedModelConnection,
|
||||
@@ -57,6 +69,10 @@ export function buildApp(dependencies: AppDependencies) {
|
||||
installErrorHandler(app);
|
||||
registerHealthRoute(app);
|
||||
registerSkillsRoute(app, effectiveDependencies);
|
||||
registerReportRoutes(app, effectiveDependencies);
|
||||
registerCmsRoutes(app, effectiveDependencies);
|
||||
registerEditorRoutes(app, effectiveDependencies);
|
||||
registerImageVideoRoutes(app, effectiveDependencies);
|
||||
registerSessionRoutes(app, effectiveDependencies);
|
||||
registerModelRoutes(app, effectiveDependencies);
|
||||
registerAttachmentRoutes(app, effectiveDependencies);
|
||||
|
||||
@@ -47,6 +47,8 @@ function mapError(error: FastifyError | Error): {
|
||||
}
|
||||
|
||||
function statusForHarnessCode(code: string): number {
|
||||
if (code === "CMS_ORIGIN_DENIED" || code === "CMS_ADMIN_REQUIRED") return 403;
|
||||
if (code === "CMS_RATE_LIMITED") return 429;
|
||||
if (code.includes("NOT_FOUND") || code === "SESSION_NOT_FOUND") return 404;
|
||||
if (code === "SESSION_BUSY" || code.includes("ALREADY_RESOLVED") || code.includes("DUPLICATE")) return 409;
|
||||
if (code === "IMAGE_TOO_LARGE") return 413;
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
ImageVideoService,
|
||||
ImageVideoMcpRemote,
|
||||
createImageVideoCollector,
|
||||
createImageVideoTools,
|
||||
IMAGE_VIDEO_RESOURCES,
|
||||
FileAttachmentStore,
|
||||
CmsConnection,
|
||||
EditorService,
|
||||
SkillRunner,
|
||||
createMediaTools,
|
||||
createEditorTools,
|
||||
EDITOR_RESOURCES,
|
||||
HarnessSession,
|
||||
HarnessError,
|
||||
MinisReportService,
|
||||
SwadsMcpClient,
|
||||
createSwadsTools,
|
||||
createMinisReportTool,
|
||||
InMemoryApprovalBroker,
|
||||
InMemoryEventStore,
|
||||
InMemoryModelConnectionStore,
|
||||
@@ -29,12 +46,21 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e
|
||||
await mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const eventStore = new InMemoryEventStore();
|
||||
const dramaSkillRoot = resolve(environment.DRAMA_MINIS_SKILL_DIR ?? join(homedir(), ".codex", "skills", "drama-minis-diagnosis"));
|
||||
const editorSkillRoot = resolve(environment.DRAMA_EDITOR_SKILL_DIR ?? join(homedir(), ".codex", "skills", "swads-short-drama-editor"));
|
||||
const imageVideoSkillRoot = resolve(environment.IMAGE_VIDEO_SKILL_DIR ?? join(homedir(), ".codex", "skills", "swads-image-to-video"));
|
||||
const imageVideoRemote = new ImageVideoMcpRemote({ token: environment.SWADS_MCP_TOKEN ?? "", receiptRoot: join(agentDir, "image-video") });
|
||||
const cmsConnections = new Map<string, CmsConnection>();
|
||||
const editors = new Map<string, EditorService>();
|
||||
const swads = new SwadsMcpClient({ token: environment.SWADS_MCP_TOKEN ?? "" });
|
||||
const reports = new WeakMap<HarnessSession, MinisReportService>();
|
||||
const skillCatalog = new SkillCatalog({
|
||||
cwd: projectRoot,
|
||||
agentDir,
|
||||
...(environment.AGENT_SKILLS_DIRS === undefined
|
||||
? {}
|
||||
: { extraDirsValue: environment.AGENT_SKILLS_DIRS }),
|
||||
extraDirsValue: [dramaSkillRoot, editorSkillRoot, imageVideoSkillRoot, environment.AGENT_SKILLS_DIRS].filter(Boolean).join(delimiter),
|
||||
resources: { [join(dramaSkillRoot, "SKILL.md")]: [
|
||||
"references/metrics.md", "references/reporting-playbook.md", "assets/report-theme.css", "scripts/generate_report.py", "examples/sample-report.json", "examples/weekly-report-example.json",
|
||||
], [join(editorSkillRoot, "SKILL.md")]: EDITOR_RESOURCES, [join(imageVideoSkillRoot, "SKILL.md")]: IMAGE_VIDEO_RESOURCES },
|
||||
});
|
||||
await skillCatalog.reload();
|
||||
const environmentConnection = await readEnvironmentConnection(environment, config.allowPrivateLlmBaseUrls);
|
||||
@@ -55,11 +81,17 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e
|
||||
maxBytes: config.maxImageBytes,
|
||||
maxAttachmentsPerMessage: config.maxImagesPerMessage,
|
||||
});
|
||||
const imageVideo = new ImageVideoService({ root: join(agentDir, "image-video"), attachments: attachmentStore, remote: imageVideoRemote, collect: createImageVideoCollector({ skillRoot: imageVideoSkillRoot, python: environment.PYTHON_EXECUTABLE ?? join(homedir(), ".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3") }) });
|
||||
const registry = new InMemorySessionRegistry<HarnessSession>({
|
||||
sessionsRoot,
|
||||
createSession: ({ sessionId, workspaceRoot }) => {
|
||||
const cms = new CmsConnection({ publishableKey: environment.CMS_SUPABASE_PUBLISHABLE_KEY ?? "" });
|
||||
cmsConnections.set(sessionId, cms);
|
||||
const editor = new EditorService({ cms, sessionId, runtimeRoot: join(projectRoot, ".agent-studio-runtime", "videos"),runner:new SkillRunner({skillRoot:editorSkillRoot,pythonExecutable:environment.PYTHON_EXECUTABLE??join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")}) });
|
||||
editors.set(sessionId, editor);
|
||||
let sessionSkillIndex = skillCatalog.index();
|
||||
eventStore.append(sessionId, { type: "session.started", payload: { cwd: "." } });
|
||||
return new HarnessSession({
|
||||
const session = new HarnessSession({
|
||||
sessionId,
|
||||
workspaceRoot,
|
||||
eventStore,
|
||||
@@ -71,31 +103,86 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e
|
||||
sessionId,
|
||||
runId,
|
||||
eventStore,
|
||||
skillIndex: skillCatalog.index(),
|
||||
skillIndex: sessionSkillIndex,
|
||||
}),
|
||||
createAgentPort: async (input) => {
|
||||
const workspace = await SafeWorkspace.create(workspaceRoot);
|
||||
const snapshot = await skillCatalog.createSessionSnapshot(workspace.root);
|
||||
sessionSkillIndex = snapshot.index;
|
||||
const drama = snapshot.skills.find((skill) => skill.name === "drama-minis-diagnosis");
|
||||
let reportService = reports.get(session);
|
||||
if (drama !== undefined && reportService === undefined) {
|
||||
reportService = new MinisReportService({ workspace, sessionId, skillRoot: drama.baseDir,
|
||||
...(environment.PYTHON_EXECUTABLE === undefined ? {} : { pythonExecutable: environment.PYTHON_EXECUTABLE }),
|
||||
...(environment.CHROME_EXECUTABLE === undefined ? {} : { chromeExecutable: environment.CHROME_EXECUTABLE }),
|
||||
});
|
||||
reports.set(session, reportService);
|
||||
}
|
||||
const service = reportService;
|
||||
return createPiAgentSessionPortFactory({
|
||||
agentDir,
|
||||
skills: snapshot.skills,
|
||||
appendSystemPrompt: ["用户选择 Skill 时必须先用 read 读取对应 SKILL.md。Drama Minis 任务必须按该 Skill 执行,并按相对路径读取 references。不要把 Skill 全文重复给用户。swads_whoami 和 metrics_catalog 是真实只读 MCP 工具;metrics_semantic_query 必须经用户确认周期、对比周期和范围,并在页面批准后才执行。不得用 mock_ads_metrics 分析真实 Minis。生成日报/周报必须用 generate_minis_report,使用本次查询返回的 sourceQueryId 和新的 JSON,不能使用示例数字。此受控工具代替 shell 运行 Python,提供 HTML、A4 PDF、JSON 下载。只在工具成功后声称文件已生成。缺少配置或数据时明确报错,不编造数据。"],
|
||||
approvalBroker,
|
||||
customTools: [
|
||||
...(snapshot.skills.some(skill => skill.name === "swads-image-to-video") ? createImageVideoTools(imageVideo, sessionId) : []),
|
||||
...(snapshot.skills.some((skill) => skill.name === "swads-short-drama-editor") ? [
|
||||
...createEditorTools(cms,()=>({rendering:true,vision:modelStore.resolveConnection(sessionId).connection.supportsImages,humanReview:true})),
|
||||
...createMediaTools(editor, () => modelStore.resolveConnection(sessionId).connection.supportsImages),
|
||||
] : []),
|
||||
createMockAdsMetricsTool(join(projectRoot, "fixtures", "ads-account.json")),
|
||||
createSaveReportDraftTool(workspace),
|
||||
...(service === undefined ? [] : [
|
||||
...createSwadsTools(swads, (args, result) => service.recordQuery(args, result)),
|
||||
createMinisReportTool(service),
|
||||
]),
|
||||
],
|
||||
})(input);
|
||||
},
|
||||
});
|
||||
return session;
|
||||
},
|
||||
});
|
||||
|
||||
return buildApp({
|
||||
const app = buildApp({
|
||||
imageVideo,
|
||||
imageVideoPorts: [config.port, 5173, 4173],
|
||||
editorForSession:(sessionId)=>{const editor=editors.get(sessionId);if(!editor)throw new HarnessError("SESSION_NOT_FOUND","Session not found");return editor;},
|
||||
readEditorFrame: (sessionId, jobId, frameId) => {
|
||||
const editor = editors.get(sessionId);
|
||||
if (!editor) throw new HarnessError("FRAME_NOT_FOUND", "Frame not found in this Session");
|
||||
return editor.readFrame(jobId, frameId);
|
||||
},
|
||||
cmsForSession: (sessionId) => {
|
||||
const cms = cmsConnections.get(sessionId);
|
||||
if (cms === undefined) throw new HarnessError("SESSION_NOT_FOUND", "Session not found");
|
||||
return cms;
|
||||
},
|
||||
sessionRegistry: registry,
|
||||
skillCatalog,
|
||||
modelStore,
|
||||
attachmentStore,
|
||||
approvalBroker,
|
||||
eventStore,
|
||||
readReport: async (sessionId, reportId, format) => {
|
||||
const session = registry.get(sessionId);
|
||||
const service = session === undefined ? undefined : reports.get(session);
|
||||
if (service === undefined) throw new HarnessError("REPORT_NOT_FOUND", "Report not found in this Session");
|
||||
return service.read(reportId, format);
|
||||
},
|
||||
});
|
||||
app.addHook("onClose", () => swads.close());
|
||||
app.addHook("onClose", () => imageVideoRemote.close());
|
||||
app.addHook("onClose", () => { editors.clear(); });
|
||||
app.addHook("onClose", () => { for (const cms of cmsConnections.values()) cms.clear(); cmsConnections.clear(); });
|
||||
app.addHook("onResponse", (request, _reply, done) => {
|
||||
if (request.method === "DELETE" && /^\/api\/sessions\/ses_[a-zA-Z0-9_-]+$/.test(request.url)) {
|
||||
const id = request.url.split("/").at(-1) ?? "";
|
||||
if (registry.get(id) === undefined) { cmsConnections.get(id)?.clear(); cmsConnections.delete(id); editors.delete(id); }
|
||||
}
|
||||
done();
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function readEnvironmentConnection(
|
||||
@@ -122,6 +209,7 @@ async function readEnvironmentConnection(
|
||||
modelId,
|
||||
api,
|
||||
supportsImages: environment.AGENT_MODEL_SUPPORTS_IMAGES !== "false",
|
||||
...(environment.AGENT_MODEL_MAX_TOKENS === undefined ? {} : { maxTokens: Number(environment.AGENT_MODEL_MAX_TOKENS) }),
|
||||
...(baseUrl === undefined ? {} : { baseUrl }),
|
||||
...(apiKey === undefined || apiKey === "" ? {} : { apiKey }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { HarnessError, type CmsConnection } from "@agent-studio/harness";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
import { requireSession } from "./sessions.js";
|
||||
|
||||
const Params = z.object({ sessionId: z.string().regex(/^ses_[a-zA-Z0-9_-]+$/) });
|
||||
const Login = z.object({ email: z.string().email().max(254), password: z.string().min(1).max(1024) }).strict();
|
||||
|
||||
export function registerCmsRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
|
||||
const attempts = new Map<string, { count: number; until: number }>();
|
||||
const connection = (request: FastifyRequest, mutate = false): CmsConnection => {
|
||||
const { sessionId } = Params.parse(request.params);
|
||||
const session = requireSession(dependencies, sessionId);
|
||||
if (mutate && session.isBusy === true) throw new HarnessError("SESSION_BUSY", "运行期间不能修改 CMS 连接");
|
||||
if (dependencies.cmsForSession === undefined) throw new HarnessError("CMS_NOT_CONFIGURED", "CMS 连接不可用");
|
||||
return dependencies.cmsForSession(sessionId);
|
||||
};
|
||||
const guard = (request: FastifyRequest): void => {
|
||||
const origin = request.headers.origin;
|
||||
if (request.headers["sec-fetch-site"] === "cross-site" || (origin !== undefined && origin !== `http://${request.headers.host ?? ""}`)) {
|
||||
throw new HarnessError("CMS_ORIGIN_DENIED", "CMS 请求必须来自当前本机页面");
|
||||
}
|
||||
};
|
||||
app.get("/api/sessions/:sessionId/cms/connection", (request, reply) => {
|
||||
guard(request);
|
||||
void reply.header("Cache-Control", "no-store");
|
||||
return { connection: connection(request).status() };
|
||||
});
|
||||
app.post("/api/sessions/:sessionId/cms/connection", async (request, reply) => {
|
||||
guard(request);
|
||||
void reply.header("Cache-Control", "no-store");
|
||||
if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("CMS_INVALID_CONTENT_TYPE", "CMS login requires JSON");
|
||||
const cms = connection(request, true);
|
||||
const input = Login.parse(request.body);
|
||||
const key = request.ip;
|
||||
const now = Date.now();
|
||||
for (const [id, value] of attempts) if (value.until < now) attempts.delete(id);
|
||||
const record = attempts.get(key) ?? { count: 0, until: now + 60_000 };
|
||||
if (record.count >= 5) throw new HarnessError("CMS_RATE_LIMITED", "登录尝试过多,请一分钟后再试");
|
||||
record.count++; attempts.set(key, record);
|
||||
try { return { connection: await cms.login(input.email, input.password) }; }
|
||||
finally { input.password = ""; }
|
||||
});
|
||||
app.delete("/api/sessions/:sessionId/cms/connection", (request, reply) => {
|
||||
guard(request);
|
||||
void reply.header("Cache-Control", "no-store");
|
||||
const cms = connection(request, true);
|
||||
cms.clear();
|
||||
return { connection: cms.status() };
|
||||
});
|
||||
app.get("/api/sessions/:sessionId/cms/dramas", async (request, reply) => {
|
||||
guard(request);
|
||||
void reply.header("Cache-Control", "no-store");
|
||||
const query = z.object({ q: z.string().max(120).default(""), offset: z.coerce.number().int().min(0).max(10_000).default(0) }).parse(request.query);
|
||||
return connection(request).listDramas(query.q, query.offset);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { HarnessError } from "@agent-studio/harness";
|
||||
import type { FastifyInstance,FastifyRequest } from "fastify";
|
||||
import {createReadStream} from "node:fs";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
import { requireSession } from "./sessions.js";
|
||||
|
||||
const Params = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/), jobId: z.string().regex(/^job_[\w-]+$/), frameId: z.string().regex(/^frame_[\w-]+$/) }).strict();
|
||||
export function registerEditorRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
|
||||
const context=(request:FastifyRequest)=>{
|
||||
const params=z.object({sessionId:z.string().regex(/^ses_[\w-]+$/),jobId:z.string().regex(/^job_[\w-]+$/)}).parse(request.params);
|
||||
requireSession(dependencies,params.sessionId);
|
||||
if(request.headers["sec-fetch-site"]==="cross-site"||(request.headers.origin!==undefined&&request.headers.origin!==`http://${request.headers.host??""}`))throw new HarnessError("CMS_ORIGIN_DENIED","媒体和审核仅限当前本机页面");
|
||||
if(!dependencies.editorForSession)throw new HarnessError("EDITOR_JOB_NOT_FOUND","Editor unavailable");
|
||||
return {...params,editor:dependencies.editorForSession(params.sessionId)};
|
||||
};
|
||||
app.get("/api/sessions/:sessionId/editor/:jobId/workflow",(request,reply)=>{const ctx=context(request);void reply.header("Cache-Control","no-store");return ctx.editor.workflowView(ctx.jobId);});
|
||||
app.post("/api/sessions/:sessionId/editor/:jobId/review/:stage",async(request,reply)=>{
|
||||
const ctx=context(request);const {stage}=z.object({stage:z.enum(["selection","plan"])}).parse(request.params);
|
||||
if(!request.headers["content-type"]?.startsWith("application/json"))throw new HarnessError("CMS_INVALID_CONTENT_TYPE","Review requires JSON");
|
||||
void reply.header("Cache-Control","no-store");return ctx.editor.review(ctx.jobId,stage,request.body);
|
||||
});
|
||||
for(const kind of ["sources","plans"] as const){
|
||||
app.get(`/api/sessions/:sessionId/editor/:jobId/${kind}/:mediaId${kind==="plans"?"/preview":""}`,async(request,reply)=>{
|
||||
const ctx=context(request);const {mediaId}=z.object({mediaId:z.string().regex(/^[\w-]{1,100}$/)}).parse(request.params);
|
||||
const media=await ctx.editor.video(ctx.jobId,kind,mediaId);
|
||||
void reply.header("Cache-Control","no-store").header("X-Content-Type-Options","nosniff").header("Accept-Ranges","bytes").type("video/mp4");
|
||||
const query=z.object({download:z.enum(["0","1"]).optional()}).parse(request.query);
|
||||
if(query.download==="1")void reply.header("Content-Disposition",`attachment; filename="${kind==="plans"?"REVIEW-preview":"licensed-source"}.mp4"`);
|
||||
const range=request.headers.range;
|
||||
if(range!==undefined){
|
||||
const match=/^bytes=(\d*)-(\d*)$/.exec(range);let start=0;let end=media.size-1;
|
||||
if(match?.[1]){start=Number(match[1]);if(match[2])end=Math.min(Number(match[2]),end);}
|
||||
else if(match?.[2]){start=Math.max(0,media.size-Number(match[2]));}
|
||||
if(!match||(!match[1]&&!match[2])||!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start>end||start>=media.size||end<0){return reply.code(416).header("Content-Range",`bytes */${String(media.size)}`).send();}
|
||||
return reply.code(206).header("Content-Range",`bytes ${String(start)}-${String(end)}/${String(media.size)}`).header("Content-Length",end-start+1).send(createReadStream(media.path,{start,end}));
|
||||
}
|
||||
return reply.header("Content-Length",media.size).send(createReadStream(media.path));
|
||||
});
|
||||
}
|
||||
app.get("/api/sessions/:sessionId/editor/:jobId/frames/:frameId", async (request, reply) => {
|
||||
const { sessionId, jobId, frameId } = Params.parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
if (!dependencies.readEditorFrame) throw new HarnessError("FRAME_NOT_FOUND", "Frame not found");
|
||||
const bytes = await dependencies.readEditorFrame(sessionId, jobId, frameId);
|
||||
return reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff").type("image/jpeg").send(bytes);
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,19 @@ export function registerImageVideoRoutes(app: FastifyInstance, dependencies: App
|
||||
if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("I2V_JSON_REQUIRED", "Draft creation requires JSON");
|
||||
void reply.header("Cache-Control", "no-store"); return { task: await service.create(sessionId, ImageVideoDraftSchema.parse(request.body)) };
|
||||
});
|
||||
app.post("/api/sessions/:sessionId/image-video/tasks/:taskId/continue", async (request, reply) => {
|
||||
const service = context(request);
|
||||
const { sessionId, taskId } = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/), taskId: taskIdSchema }).parse(request.params);
|
||||
const session = requireSession(dependencies, sessionId);
|
||||
if (session.isBusy) throw new HarnessError("SESSION_BUSY", "请等待当前会话结束再接续任务。");
|
||||
if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("I2V_JSON_REQUIRED", "Continuation requires JSON");
|
||||
const { expectedRevision } = z.object({ confirmed: z.literal(true), expectedRevision: z.number().int().nonnegative() }).strict().parse(request.body);
|
||||
void reply.header("Cache-Control", "no-store");
|
||||
return { task: await service.continueTask(sessionId, taskId, expectedRevision, owner => dependencies.sessionRegistry.get(owner) !== undefined, () => {
|
||||
const current = requireSession(dependencies, sessionId);
|
||||
if (current !== session || current.isBusy) throw new HarnessError("SESSION_BUSY", "会话状态已改变,请重新确认接续。");
|
||||
}) };
|
||||
});
|
||||
for (const kind of ["reference", "video"] as const) app.get(`/api/image-video/tasks/:taskId/${kind}`, async (request, reply) => {
|
||||
const service = context(request); const { taskId } = z.object({ taskId: taskIdSchema }).parse(request.params);
|
||||
const query = z.object({ download: z.literal("1").optional() }).strict().parse(request.query);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { HarnessError } from "@agent-studio/harness";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
import { requireSession } from "./sessions.js";
|
||||
|
||||
const Params = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/), reportId: z.string().regex(/^rpt_[\w-]+$/), format: z.enum(["html", "pdf", "json"]) }).strict();
|
||||
|
||||
export function registerReportRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
|
||||
app.get("/api/sessions/:sessionId/reports/:reportId/:format", async (request, reply) => {
|
||||
const { sessionId, reportId, format } = Params.parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
if (dependencies.readReport === undefined) throw new HarnessError("REPORT_NOT_FOUND", "Report not found");
|
||||
const file = await dependencies.readReport(sessionId, reportId, format);
|
||||
return reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff")
|
||||
.header("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:")
|
||||
.header("Content-Disposition", `attachment; filename="${reportId}.${format}"`)
|
||||
.type(file.contentType).send(file.bytes);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CmsConnection } from "@agent-studio/harness";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestApp } from "./test-app.js";
|
||||
|
||||
describe("CMS API safety", () => {
|
||||
it("exposes only redacted connection status and rejects cross-origin login before contacting CMS", async () => {
|
||||
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: () => Promise.reject(new Error("Must not reach CMS")) });
|
||||
const { app } = createTestApp({ cmsForSession: () => cms });
|
||||
try {
|
||||
const status = await app.inject({ method: "GET", url: "/api/sessions/ses_1/cms/connection" });
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(status.json()).toMatchObject({ connection: { connected: false, configured: true } });
|
||||
expect(status.headers["cache-control"]).toBe("no-store");
|
||||
const denied = await app.inject({ method: "POST", url: "/api/sessions/ses_1/cms/connection", headers: { origin: "https://attacker.example", "sec-fetch-site": "cross-site" }, payload: { email: "fixture@example.com", password: "never-expose" } });
|
||||
expect(denied.statusCode).toBe(403);
|
||||
expect(denied.body).not.toContain("never-expose");
|
||||
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/cms/connection" })).statusCode).toBe(404);
|
||||
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/cms/dramas" })).json()).toMatchObject({ error: { code: "CMS_LOGIN_REQUIRED" } });
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HarnessError } from "@agent-studio/harness";
|
||||
import { createTestApp } from "./test-app.js";
|
||||
import {mkdtemp,writeFile,rm} from "node:fs/promises";
|
||||
import {tmpdir} from "node:os";
|
||||
import {join} from "node:path";
|
||||
|
||||
describe("private editor frames", () => {
|
||||
it("only serves a registered Session frame and forbids caching", async () => {
|
||||
const { app } = createTestApp({ readEditorFrame: (sessionId, jobId, frameId) => {
|
||||
if (sessionId !== "ses_1" || jobId !== "job_known" || frameId !== "frame_known") throw new HarnessError("FRAME_NOT_FOUND", "Missing frame");
|
||||
return Promise.resolve(Buffer.from([255, 216, 255, 217]));
|
||||
} });
|
||||
try {
|
||||
const response = await app.inject({ method: "GET", url: "/api/sessions/ses_1/editor/job_known/frames/frame_known" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toBe("image/jpeg");
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.rawPayload).toEqual(Buffer.from([255, 216, 255, 217]));
|
||||
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/editor/job_known/frames/frame_known" })).statusCode).toBe(404);
|
||||
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/editor/job_known/frames/secret.env" })).statusCode).toBe(400);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
it("streams only registered media ranges and keeps review mutations same-origin",async()=>{
|
||||
const root=await mkdtemp(join(tmpdir(),"editor-api-"));const path=join(root,"preview.mp4");await writeFile(path,Buffer.from("0123456789"));
|
||||
const {app}=createTestApp({editorForSession:()=>({workflowView:()=>({jobId:"job_test"}),review:()=>Promise.resolve({saved:true}),video:()=>Promise.resolve({path,size:10})})});
|
||||
try{
|
||||
const range=await app.inject({method:"GET",url:"/api/sessions/ses_1/editor/job_test/plans/plan_test/preview",headers:{range:"bytes=2-5"}});
|
||||
expect(range.statusCode).toBe(206);expect(range.body).toBe("2345");expect(range.headers["content-range"]).toBe("bytes 2-5/10");expect(range.headers["content-type"]).toBe("video/mp4");
|
||||
expect((await app.inject({method:"GET",url:"/api/sessions/ses_1/editor/job_test/plans/plan_test/preview",headers:{range:"bytes=20-30"}})).statusCode).toBe(416);
|
||||
expect((await app.inject({method:"GET",url:"/api/sessions/ses_missing/editor/job_test/plans/plan_test/preview"})).statusCode).toBe(404);
|
||||
expect((await app.inject({method:"POST",url:"/api/sessions/ses_1/editor/job_test/review/selection",headers:{origin:"https://evil.example","content-type":"application/json"},payload:{}})).statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect((await app.inject({method:"POST",url:"/api/sessions/ses_1/editor/job_test/review/selection",payload:{}})).json()).toEqual({saved:true});
|
||||
}finally{await app.close();await rm(root,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -13,10 +13,43 @@ async function fixture() {
|
||||
const image = await attachments.add("ses_1", { name: "pixel.png", mediaType: "image/png", stream: Readable.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAE0lEQVQImWP4z8DwnwGM/zMwAAAf7gP9qS/A4gAAAABJRU5ErkJggg==", "base64")) });
|
||||
const service = new ImageVideoService({ root: join(root, "tasks"), attachments, remote: {} as never, collect: () => Promise.reject(new Error("No external operations permitted")) });
|
||||
const dependencies = { imageVideo: service, sessionRegistry: { get: (id: string) => id === "ses_1" ? { sessionId: id } : undefined, disposeAll: async () => {} }, skillCatalog: { list: () => [] }, modelStore: {}, attachmentStore: attachments, approvalBroker: {} } as unknown as AppDependencies;
|
||||
return { app: buildApp(dependencies), service, draft: { attachmentId: image.attachmentId, title: "Reference", prompt: "Natural motion and ambient sound", durationSeconds: 10 } };
|
||||
return { app: buildApp(dependencies), service, dependencies, draft: { attachmentId: image.attachmentId, title: "Reference", prompt: "Natural motion and ambient sound", durationSeconds: 10 } };
|
||||
}
|
||||
|
||||
describe("image video local API", () => {
|
||||
it("does not persist a target session that disappeared during continuation", async () => {
|
||||
const { app, service, dependencies, draft } = await fixture();
|
||||
const task = await service.create("ses_1", draft);
|
||||
let lookups = 0;
|
||||
dependencies.sessionRegistry.get = id => id === "ses_new" && lookups++ === 0 ? { sessionId: id } as never : undefined;
|
||||
try {
|
||||
const result = await app.inject({ method: "POST", url: `/api/sessions/ses_new/image-video/tasks/${task.id}/continue`, payload: { confirmed: true, expectedRevision: 0 } });
|
||||
expect(result.statusCode).toBe(404);
|
||||
expect((await service.owned("ses_1", task.id)).revision).toBe(0);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
it("requires an explicit same-origin confirmation to continue an orphaned task in a live session", async () => {
|
||||
const { app, service, dependencies, draft } = await fixture();
|
||||
const task = await service.create("ses_1", draft);
|
||||
const liveSession = { sessionId: "ses_new" } as never;
|
||||
dependencies.sessionRegistry.get = id => id === "ses_new" ? liveSession : undefined;
|
||||
const url = `/api/sessions/ses_new/image-video/tasks/${task.id}/continue`;
|
||||
const payload = { confirmed: true, expectedRevision: 0 };
|
||||
try {
|
||||
expect((await app.inject({ method: "POST", url, payload: { expectedRevision: 0 } })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: "POST", url, payload: { ...payload, confirmed: false } })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: "POST", url, payload, headers: { origin: "https://evil.example" } })).statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect((await app.inject({ method: "POST", url, payload, headers: { "sec-fetch-site": "cross-site" } })).statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect((await app.inject({ method: "POST", url: url.replace("ses_new", "ses_unknown"), payload })).statusCode).toBe(404);
|
||||
expect((await app.inject({ method: "POST", url, payload: { ...payload, prompt: "changed" } })).statusCode).toBe(400);
|
||||
await expect(service.owned("ses_new", task.id)).rejects.toMatchObject({ code: "I2V_TASK_NOT_FOUND" });
|
||||
const result = await app.inject({ method: "POST", url, payload });
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.json<{ task: ImageVideoPublicTask }>().task).toMatchObject({ id: task.id, state: "draft", revision: 1 });
|
||||
expect((await service.owned("ses_new", task.id)).prompt).toBe(draft.prompt);
|
||||
expect((await service.list())).toHaveLength(1);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
it("creates locally, persists history independently from Sessions and serves registered reference ranges", async () => {
|
||||
const { app, service, draft } = await fixture();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestApp } from "./test-app.js";
|
||||
|
||||
describe("report download API", () => {
|
||||
it("requires a known Session and serves only a fixed report format with sandbox and no-store headers", async () => {
|
||||
const { app } = createTestApp({
|
||||
readReport: (_session, _report, format) => Promise.resolve({ bytes: Buffer.from("<!doctype html><p>report</p>"), contentType: format === "html" ? "text/html" : "application/json" }),
|
||||
});
|
||||
try {
|
||||
const response = await app.inject({ method: "GET", url: "/api/sessions/ses_1/reports/rpt_test/html" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain("<p>report</p>");
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.headers["content-security-policy"]).toContain("sandbox");
|
||||
expect(response.headers["content-disposition"]).toContain("attachment");
|
||||
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/reports/rpt_test/html" })).statusCode).toBe(404);
|
||||
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/reports/rpt_test/env" })).statusCode).toBe(400);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { HarnessError, InMemoryEventStore } from "@agent-studio/harness";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { buildApp, type AppDependencies } from "../src/app.js";
|
||||
|
||||
export function createTestApp(): {
|
||||
export function createTestApp(options: { editorForSession?:AppDependencies["editorForSession"]; readReport?: AppDependencies["readReport"]; readEditorFrame?: AppDependencies["readEditorFrame"]; cmsForSession?: AppDependencies["cmsForSession"] } = {}): {
|
||||
app: FastifyInstance;
|
||||
session: { startRun: ReturnType<typeof vi.fn>; cancelRun: ReturnType<typeof vi.fn> };
|
||||
modelStore: {
|
||||
@@ -60,6 +60,10 @@ export function createTestApp(): {
|
||||
};
|
||||
const eventStore = new InMemoryEventStore();
|
||||
const app = buildApp({
|
||||
...(options.editorForSession===undefined?{}:{editorForSession:options.editorForSession}),
|
||||
...(options.readEditorFrame === undefined ? {} : { readEditorFrame: options.readEditorFrame }),
|
||||
...(options.readReport === undefined ? {} : { readReport: options.readReport }),
|
||||
...(options.cmsForSession === undefined ? {} : { cmsForSession: options.cmsForSession }),
|
||||
sessionRegistry: registry,
|
||||
skillCatalog: { list: () => [{ name: "ads-analysis", description: "Analyze", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", diagnostics: [] }] },
|
||||
modelStore,
|
||||
|
||||
+16
-2
@@ -4,13 +4,15 @@ import type { ImageAttachment, ModelConnectionInput } from "@agent-studio/shared
|
||||
import { HarnessAttachmentAdapter } from "./assistant/attachment-adapter.js";
|
||||
import { useHarnessRuntime, type HarnessRuntimeStore } from "./assistant/use-harness-runtime.js";
|
||||
import { Thread } from "./components/chat/Thread.js";
|
||||
import { AppShell } from "./components/layout/AppShell.js";
|
||||
import { AppShell, type WorkspaceTab } from "./components/layout/AppShell.js";
|
||||
import { SkillsPanel } from "./components/skills/SkillsPanel.js";
|
||||
import { CmsPanel } from "./components/cms/CmsPanel.js";
|
||||
import { EventTimeline } from "./components/timeline/EventTimeline.js";
|
||||
import { ApprovalDialog } from "./components/approvals/ApprovalDialog.js";
|
||||
import { ModelSettings, type ModelView } from "./components/settings/ModelSettings.js";
|
||||
import { CompletionAnnouncer } from "./components/common/CompletionAnnouncer.js";
|
||||
import { HarnessClientStore } from "./store/harness-client-store.js";
|
||||
import { ImageVideoPanel } from "./components/tools/ImageVideoPanel.js";
|
||||
|
||||
type AppStore = HarnessRuntimeStore & {
|
||||
start(): Promise<void>;
|
||||
@@ -35,6 +37,7 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
|
||||
const [ownedStore] = useState(() => new HarnessClientStore());
|
||||
const store = suppliedStore ?? ownedStore;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<WorkspaceTab>("chat");
|
||||
const snapshot = useSyncExternalStore(
|
||||
(subscriber) => store.subscribe(subscriber),
|
||||
() => store.getSnapshot(),
|
||||
@@ -82,6 +85,8 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<AppShell
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
topBar={{
|
||||
...(snapshot.sessionId === undefined ? {} : { sessionId: snapshot.sessionId }),
|
||||
connection: snapshot.connection,
|
||||
@@ -100,7 +105,15 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
|
||||
.catch(() => undefined);
|
||||
},
|
||||
}}
|
||||
skills={<SkillsPanel skills={snapshot.skills} />}
|
||||
skills={<><ImageVideoPanel disabled={busy || approvalPending} {...(snapshot.sessionId && snapshot.skills.some(skill => skill.name === "swads-image-to-video") ? { sessionId: snapshot.sessionId } : {})} onDraft={task => { runtime.thread.composer.setText(`请使用 swads-image-to-video,先读取完整 Skill 和 references。本地冻结任务已创建 taskId=${task.id},标题=${task.title},精确提示词=${task.prompt},目标=${String(task.durationSeconds)} 秒。不要重复创建任务,不使用 CMS。${task.state === "awaiting_submission" ? "参考图已上传入库,本任务已接续到当前会话;不得重新上传。先用 i2v_status 核对状态,再申请 i2v_submit 独立生成审批。" : "先申请 i2v_upload_reference 独立图片外传审批;参考图入库就绪后再申请 i2v_submit 独立生成审批。"}不得改变提示词或时长,结果未知禁止重投。`); setActiveTab("chat"); }} /><SkillsPanel skills={snapshot.skills} disabled={busy || !model.configured} onUse={(name) => { runtime.thread.composer.setText(
|
||||
name === "drama-minis-diagnosis"
|
||||
? "请使用 drama-minis-diagnosis Skill 生成 Drama Minis 日报。先读取 Skill,向我确认分析周期、对比周期和账户范围,确认前不要查询经营数据。"
|
||||
: name === "swads-short-drama-editor"
|
||||
? "请使用 swads-short-drama-editor。先读取完整 Skill 和 CMS source contract,再检查 cms_connection_status,说明实际可用能力,并询问我要剪哪部剧。不要在聊天索要密码,不要在能力未就绪时声称视频已生成。"
|
||||
: `请使用 ${name} Skill,先读取指令并告诉我需要补充哪些任务信息。`,
|
||||
); setActiveTab("chat"); }} />{snapshot.sessionId === undefined ? null : <CmsPanel key={snapshot.sessionId} sessionId={snapshot.sessionId} busy={busy} onSelect={(drama) => {
|
||||
runtime.thread.composer.setText(`请使用 swads-short-drama-editor 分析这部授权剧:${drama.title},精确 drama_id=${drama.id}。先读取 Skill 和参考文件,获取完整目录前申请审批。不要在缺少画面/参考证据时生成诊断 PASS 或渲染视频。`); setActiveTab("chat");
|
||||
}} />}</>}
|
||||
chat={<Thread
|
||||
runtime={runtime}
|
||||
busy={busy}
|
||||
@@ -114,6 +127,7 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
|
||||
/>
|
||||
{pendingApproval !== undefined ? (
|
||||
<ApprovalDialog
|
||||
key={pendingApproval.approvalId}
|
||||
request={pendingApproval}
|
||||
onApprove={(approvalId) => store.approve?.(approvalId) ?? Promise.reject(new Error("Approval API unavailable"))}
|
||||
onDeny={(approvalId) => store.deny?.(approvalId) ?? Promise.reject(new Error("Approval API unavailable"))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ImageVideoApprovalDetails } from "../tools/ImageVideoPanel.js";
|
||||
import type { ClientApproval } from "../../store/reducer.js";
|
||||
|
||||
export function ApprovalDialog({ request, onApprove, onDeny }: {
|
||||
@@ -10,6 +11,17 @@ export function ApprovalDialog({ request, onApprove, onDeny }: {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const approveRef = useRef<HTMLButtonElement>(null);
|
||||
const pending = request.status === "pending";
|
||||
const query = request.toolName === "metrics_semantic_query";
|
||||
const minis = request.toolName === "generate_minis_report";
|
||||
const cms = ["cms_list_dramas", "cms_drama_inventory", "cms_read_subtitle"].includes(request.toolName);
|
||||
const prepare = request.toolName === "prepare_drama_sources";
|
||||
const frames = request.toolName === "read_drama_frames";
|
||||
const render = request.toolName === "render_editor_preview";
|
||||
const i2vUpload = request.toolName === "i2v_upload_reference";
|
||||
const i2vSubmit = request.toolName === "i2v_submit";
|
||||
const i2v = i2vUpload || i2vSubmit;
|
||||
const [taskReady, setTaskReady] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [remaining, setRemaining] = useState(() => secondsRemaining(request.expiresAt));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,7 +33,7 @@ export function ApprovalDialog({ request, onApprove, onDeny }: {
|
||||
}, [request.expiresAt]);
|
||||
|
||||
const decide = async (decision: "approve" | "deny") => {
|
||||
if (submitting || !pending) return;
|
||||
if (submitting || !pending || (decision === "approve" && i2v && (!taskReady || !confirmed))) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await (decision === "approve" ? onApprove(request.approvalId) : onDeny(request.approvalId));
|
||||
@@ -41,17 +53,19 @@ export function ApprovalDialog({ request, onApprove, onDeny }: {
|
||||
>
|
||||
<span className="dialog-icon dialog-icon--warning"><ShieldAlert size={22} aria-hidden="true" /></span>
|
||||
<span className="eyebrow">SERVER APPROVAL</span>
|
||||
<h2 id="approval-title">允许写入报告草稿吗?</h2>
|
||||
<p>Agent 请求执行 <code>{request.toolName}</code>。批准后只会写入当前训练会话的固定报告文件。</p>
|
||||
<h2 id="approval-title">{i2vUpload ? "允许将这张参考图上传到 SW Ads 吗?" : i2vSubmit ? "允许提交本次 H3 生成吗?" : render ? "允许生成 10 秒 REVIEW 预览吗?" : prepare ? "允许下载所选分集并在本机抽帧吗?" : frames ? "允许将字幕和代表帧发送给当前模型吗?" : cms ? "允许读取 CMS 内容并交给模型吗?" : query ? "允许查询真实经营数据吗?" : minis ? "允许生成 HTML / PDF 报告吗?" : "允许写入报告草稿吗?"}</h2>
|
||||
<p>Agent 请求执行 <code>{request.toolName}</code>。{i2vUpload ? "将本次冻结参考图发送到 SW Ads 存储,并创建 review_pending、未分配账户的参考素材记录。" : i2vSubmit ? "将下方冻结图片和提示词提交到共享 H3 队列。费用未知,目标时长支持尚待实际验证;不自动换模型或重投。" : render ? "仅在本机运行原 Skill 渲染和技术 QA,使用已审核版本的授权片源及原声,生成可播放下载的 REVIEW MP4。不会发布、上传或替代最终带声音审片。" : prepare ? "仅将所选分集下载到本机并抽帧,每次最多 5 集 / 1 GiB。不会修改 CMS;此步仅返回元数据,不向模型发送字幕或图片。" : frames ? "将此任务的英文字幕和每集 3 张代表帧发送给当前配置的模型网关。代表帧分析不能替代完整带声音审片。请核对任务范围。" : cms ? "本次剧集简介、目录或字幕可能包含未公开内容,将发送给当前配置的模型网关。请核对剧集和读取范围;不会修改 CMS。" : query ? "请核对日期、账户范围和指标。查询结果将发送给当前配置的模型网关进行分析,不会更改广告投放。" : "批准后只会写入当前训练会话的报告文件。"}</p>
|
||||
{i2v ? <><ImageVideoApprovalDetails taskId={(request.arguments as { taskId?: unknown } | undefined)?.taskId} onReady={setTaskReady} /><label><input type="checkbox" checked={confirmed} disabled={!taskReady || submitting} onChange={event => setConfirmed(event.currentTarget.checked)} />我已核对本次图片、提示词、时长和上述副作用</label></> : null}
|
||||
{i2v || query || minis || cms || prepare || frames || render ? <details open={!minis}><summary>{minis ? "报告数据与来源" : "本次执行参数"}</summary><pre className="approval-arguments">{JSON.stringify(request.arguments, null, 2)}</pre></details> : null}
|
||||
<dl className="approval-details">
|
||||
<div><dt>风险</dt><dd>{riskLabel(request.riskLevel)}</dd></div>
|
||||
<div><dt>目标</dt><dd>report.md</dd></div>
|
||||
<div><dt>目标</dt><dd>{i2vUpload ? "本机参考图 → SW Ads 存储(待审核、未归属)" : i2vSubmit ? "本次参考图与提示词 → H3 ref2va 生成" : render ? "已审核片源 → 本机 REVIEW MP4" : prepare ? "CMS CDN → 本机媒体目录" : frames ? "本机授权素材 → 当前模型网关" : cms ? "SWAVE CMS → 当前模型网关" : query ? "swads MCP → 当前模型网关" : minis ? "report.html / report.pdf / report.json" : "report.md"}</dd></div>
|
||||
<div><dt>审批 ID</dt><dd><code>{request.approvalId}</code></dd></div>
|
||||
<div><dt>剩余时间</dt><dd>{remaining > 0 ? `${String(remaining)} 秒` : "等待服务器确认"}</dd></div>
|
||||
</dl>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="secondary-button" disabled={submitting || !pending} onClick={() => void decide("deny")}>拒绝</button>
|
||||
<button ref={approveRef} type="button" className="send-button" disabled={submitting || !pending} onClick={() => void decide("approve")}>批准并保存</button>
|
||||
<button ref={approveRef} type="button" className="send-button" disabled={submitting || !pending || (i2v && (!taskReady || !confirmed))} onClick={() => void decide("approve")}>{i2vUpload ? "批准参考图上传" : i2vSubmit ? "批准 H3 生成" : render ? "批准本次渲染" : prepare ? "批准本次下载" : frames ? "批准本次分析" : query || cms ? "批准本次查询" : minis ? "批准生成报告" : "批准并保存"}</button>
|
||||
</div>
|
||||
<p className="dialog-note">提交后等待服务器事件确认,重复点击不会重复执行。</p>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
|
||||
type Connection = { configured: boolean; connected: boolean; expiresAt?: string };
|
||||
type Drama = { id: string; title: string; language: string; total_episodes: number };
|
||||
type ApiResult = { connection?: Connection; dramas?: Drama[] };
|
||||
|
||||
export function CmsPanel({ sessionId, busy, onSelect }: { sessionId: string; busy: boolean; onSelect: (drama: Drama) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [connection, setConnection] = useState<Connection>();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [dramas, setDramas] = useState<Drama[]>([]);
|
||||
const base = `/api/sessions/${encodeURIComponent(sessionId)}/cms`;
|
||||
const request = async (path: string, method = "GET", body?: unknown): Promise<ApiResult> => {
|
||||
const response = await fetch(`${base}${path}`, { method, cache: "no-store", headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
if (!response.ok) {
|
||||
if (path === "/connection" && method === "POST") throw new Error("CMS 登录失败:请检查账户、密码和管理员权限;若启用了验证码需先完成相应认证。");
|
||||
throw new Error("CMS 请求失败,请检查连接是否过期并重新登录。");
|
||||
}
|
||||
return await response.json() as ApiResult;
|
||||
};
|
||||
const act = async (operation: () => Promise<void>) => {
|
||||
if (pending) return;
|
||||
setPending(true); setError("");
|
||||
try { await operation(); } catch (failure) { setError(failure instanceof Error ? failure.message : "CMS 请求失败"); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const loadDramas = async (page: number) => {
|
||||
const result = await request(`/dramas?q=${encodeURIComponent(query)}&offset=${String(page)}`);
|
||||
setDramas(result.dramas ?? []); setOffset(page);
|
||||
};
|
||||
return <section className="cms-panel" aria-label="CMS 素材连接">
|
||||
<button className="secondary-button" type="button" aria-expanded={open} onClick={() => {
|
||||
setPassword(""); setOpen(!open);
|
||||
if (!open) void act(async () => { setConnection((await request("/connection")).connection); });
|
||||
}}>CMS 连接与选剧</button>
|
||||
{open ? <div className="cms-panel__body">
|
||||
<p>使用 SWAVE 的 Supabase 管理员账户。密码通过本机后端发送至 shorts-sg 登录服务,不进入聊天或浏览器存储。</p>
|
||||
<p>已提供选段审核和 10 秒 REVIEW 预览工具;需先授权素材分析、通过原始校验并核实参考。技术预览不等于最终审片通过。</p>
|
||||
{connection?.connected ? <>
|
||||
<p role="status">CMS 已连接(本次会话)</p>
|
||||
<button type="button" className="secondary-button" disabled={pending || busy} onClick={() => void act(async () => { setConnection((await request("/connection", "DELETE")).connection); setDramas([]); })}>断开 CMS</button>
|
||||
<form onSubmit={(event) => { event.preventDefault(); void act(() => loadDramas(0)); }}>
|
||||
<label>搜索剧名<input aria-label="搜索剧名" value={query} maxLength={120} onChange={(event) => setQuery(event.target.value)} /></label>
|
||||
<button className="secondary-button" disabled={pending} type="submit">读取剧集目录</button>
|
||||
</form>
|
||||
<ul className="cms-drama-list">{dramas.map((drama) => <li key={drama.id}><strong>{drama.title}</strong><span>{drama.language} · {drama.total_episodes} 集</span><button type="button" className="secondary-button" disabled={busy || pending} onClick={() => onSelect(drama)}>选择此剧</button></li>)}</ul>
|
||||
{dramas.length || offset ? <div><button type="button" disabled={pending || offset === 0} onClick={() => void act(() => loadDramas(Math.max(0, offset - 30)))}>上一页</button><button type="button" disabled={pending || dramas.length < 30} onClick={() => void act(() => loadDramas(offset + 30))}>下一页</button></div> : null}
|
||||
</> : <form autoComplete="off" onSubmit={(event) => {
|
||||
event.preventDefault(); if (busy || pending) return;
|
||||
const enteredPassword = password; setPassword("");
|
||||
void act(async () => { setConnection((await request("/connection", "POST", { email, password: enteredPassword })).connection); });
|
||||
}}>
|
||||
<label>CMS 邮箱<input type="email" aria-label="CMS 邮箱" value={email} required autoComplete="off" maxLength={254} onChange={(event) => setEmail(event.target.value)} /></label>
|
||||
<label>CMS 密码<input type="password" aria-label="CMS 密码" value={password} required autoComplete="new-password" maxLength={1024} onChange={(event) => setPassword(event.target.value)} /></label>
|
||||
<button className="send-button" type="submit" disabled={pending || busy || connection?.configured === false}>{pending ? "连接中…" : "登录 CMS"}</button>
|
||||
{connection?.configured === false ? <p>后端尚未配置 CMS publishable key。</p> : null}
|
||||
</form>}
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
</div> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import { Activity, MessageSquare, PanelsTopLeft } from "lucide-react";
|
||||
import { useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { TopBar, type TopBarProps } from "../topbar/TopBar.js";
|
||||
|
||||
type WorkspaceTab = "chat" | "skills" | "events";
|
||||
export type WorkspaceTab = "chat" | "skills" | "events";
|
||||
|
||||
export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarProps; skills: ReactNode; chat: ReactNode; timeline: ReactNode }) {
|
||||
const [activeTab, setActiveTab] = useState<WorkspaceTab>("chat");
|
||||
export function AppShell({ topBar, skills, chat, timeline, activeTab, onTabChange: setActiveTab }: { topBar: TopBarProps; skills: ReactNode; chat: ReactNode; timeline: ReactNode; activeTab: WorkspaceTab; onTabChange: (tab: WorkspaceTab) => void }) {
|
||||
const [mobile, setMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AlertCircle, BookOpen, Check, LoaderCircle } from "lucide-react";
|
||||
import type { ClientSkill } from "../../store/reducer.js";
|
||||
|
||||
export function SkillsPanel({ skills }: { skills: ClientSkill[] }) {
|
||||
export function SkillsPanel({ skills, onUse, disabled = false }: { skills: ClientSkill[]; onUse?: (name: string) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<section className="side-panel skills-panel" aria-label="Skills Catalog">
|
||||
<div className="panel-heading">
|
||||
@@ -24,6 +24,7 @@ export function SkillsPanel({ skills }: { skills: ClientSkill[] }) {
|
||||
{skill.activity === "loaded" ? <Check size={15} aria-label="已加载" /> : null}
|
||||
</div>
|
||||
<p>{skill.description}</p>
|
||||
{onUse === undefined ? null : <button type="button" className="secondary-button" aria-label={`使用 ${skill.name}`} disabled={disabled} onClick={() => onUse(skill.name)}>使用此 Skill</button>}
|
||||
<div className="skill-meta"><span>{skill.source}</span><code>{skill.filePath}</code></div>
|
||||
{skill.diagnostics.map((diagnostic, index) => (
|
||||
<div className="diagnostic" key={`${diagnostic.message}:${String(index)}`}>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {useState,type FormEvent} from "react";
|
||||
|
||||
type Workflow={jobId:string;selection:null|{hash:string;reviewed:boolean;segments:{id:string;episodeNo:number;start:number;end:number;reason:string;videoUrl:string;subtitles:string}[]};plan:null|{hash:string;reviewed:boolean;reference:Record<string,unknown>;artifacts:Record<string,unknown>;subtitles:string;segmentIds:string[]};preview:null|{url:string;duration:number;sha256:string;machinePass:boolean;checks:Record<string,boolean>}};
|
||||
const urlPattern=/^\/api\/sessions\/ses_[\w-]+\/editor\/job_[\w-]+\/workflow$/;
|
||||
export function EditorWorkflowPanel({url}:{url:string}){
|
||||
const [data,setData]=useState<Workflow|null>(null);const [busy,setBusy]=useState(false);const [message,setMessage]=useState("");
|
||||
if(!urlPattern.test(url))return null;
|
||||
const base=url.slice(0,-"/workflow".length);
|
||||
const allowedVideo=(value:string,kind:"sources"|"plans")=>typeof value==="string"&&value.startsWith(`${base}/${kind}/`)&&new RegExp(`^${kind}/[\\w-]+${kind==="plans"?"/preview":""}$`).test(value.slice(base.length+1));
|
||||
const request=async(target:string,body?:unknown)=>{
|
||||
setBusy(true);setMessage("");
|
||||
try{
|
||||
const response=await fetch(target,body===undefined?{cache:"no-store"}:{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
|
||||
if(!response.ok){const failure=await response.json() as {error?:{message?:unknown}};throw new Error(typeof failure.error?.message==="string"?failure.error.message:`请求未通过(${String(response.status)}),请刷新状态并核对审核记录。`);}
|
||||
const next=await response.json() as Workflow;
|
||||
if(typeof next.jobId!=="string"||!base.endsWith(`/editor/${next.jobId}`))throw new Error("媒体任务不匹配");
|
||||
setData(next);if(body!==undefined)setMessage("审核记录已保存。请在聊天中输入“继续”,让 Agent 执行下一步。");
|
||||
}catch(error){setMessage(error instanceof Error?error.message:"请求失败");}finally{setBusy(false);}
|
||||
};
|
||||
const selected=data?.selection;const plan=data?.plan;
|
||||
const stage=selected&&!selected.reviewed?"selection":plan&&!plan.reviewed?"plan":undefined;
|
||||
const submit=(event:FormEvent<HTMLFormElement>)=>{
|
||||
event.preventDefault();if(!stage||!selected)return;
|
||||
const fields=new FormData(event.currentTarget);const text=(name:string)=>{const value=fields.get(name);return typeof value==="string"?value:"";};
|
||||
const checked=(name:string)=>fields.get(name)==="on";
|
||||
const common={planHash:stage==="selection"?selected.hash:plan?.hash,reviewer:text("reviewer"),policyCheckedAt:new Date(text("policyCheckedAt")).toISOString(),pictureAudioReviewed:checked("pictureAudioReviewed"),noUnresolvedFindings:checked("noUnresolvedFindings")};
|
||||
const payload=stage==="selection"?{...common,policyPageUpdated:text("policyPageUpdated"),segmentEvidence:selected.segments.map(s=>({segmentId:s.id,pictureEvidence:text(`${s.id}-picture`),audioEvidence:text(`${s.id}-audio`),subtitleEvidence:text(`${s.id}-subtitle`)}))}:{...common,referenceReviewed:checked("referenceReviewed"),storyReviewed:checked("storyReviewed"),packagingReviewed:checked("packagingReviewed"),segmentEvidence:(plan?.segmentIds??[]).map(id=>({segmentId:id,observation:text(`${id}-observation`)}))};
|
||||
void request(`${base}/review/${stage}`,payload);
|
||||
};
|
||||
return <section className="editor-workflow" aria-label="剪辑审核与预览">
|
||||
<button className="secondary-button" type="button" disabled={busy} onClick={()=>void request(url)}>{data?"刷新剪辑状态":"打开剪辑审核与预览"}</button>
|
||||
{message?<p role="status">{message}</p>:null}
|
||||
{data?<>
|
||||
<p>分析已通过不等于成片。选段审核、方案审核和最终审片分别记录;不会上传或发布广告。</p>
|
||||
{selected?<div><strong>候选源片段 · {selected.reviewed?"已有人工审核记录":"待带声音审核"}</strong>
|
||||
{selected.segments.map(span=><div key={span.id} className="editor-review-segment">
|
||||
<p>选段 {span.id} · 第 {span.episodeNo} 集 · {span.start}–{span.end} 秒</p><p>{span.reason}</p>
|
||||
{allowedVideo(span.videoUrl,"sources")?<video aria-label={`选段 ${span.id} 原声视频`} src={`${span.videoUrl}#t=${String(span.start)},${String(span.end)}`} controls playsInline preload="metadata" onLoadedMetadata={e=>{e.currentTarget.currentTime=span.start;}} onTimeUpdate={e=>{if(e.currentTarget.currentTime>=span.end)e.currentTarget.pause();}}/>:<p>源视频地址无效,不能确认已审片。</p>}
|
||||
<details><summary>该选段原始英文字幕</summary><pre>{span.subtitles||"所选区间没有字幕 cue,仍需实际听原声。"}</pre></details>
|
||||
</div>)}
|
||||
</div>:<p>等待 Agent 提交候选选段。</p>}
|
||||
{plan?<div><strong>剪辑方案 · {plan.reviewed?"已有人工审核记录":"待核对参考、故事与字幕"}</strong>
|
||||
<details><summary>本次参考证据与完整方案</summary><pre>{JSON.stringify({reference:plan.reference,artifacts:plan.artifacts,finalSubtitles:plan.subtitles},null,2)}</pre></details>
|
||||
</div>:null}
|
||||
{stage&&selected?<form key={stage==="selection"?selected.hash:plan?.hash} onSubmit={submit}>
|
||||
<p>请亲自观看完整选段并开启声音。下列记录由审核人填写,模型不能代勾。若内容不合适,不要确认,请在聊天中要求替换完整场景。</p>
|
||||
<a href="https://ads.tiktok.com/resources/help/article/tiktok-ads-policy-adult-content" target="_blank" rel="noreferrer">核对 TikTok Adult Content 官方政策</a>
|
||||
<label>审核人<input name="reviewer" required maxLength={100}/></label>
|
||||
<label>本次实际政策核对时间<input name="policyCheckedAt" type="datetime-local" required/></label>
|
||||
{stage==="selection"?<label>政策页面显示的更新日期<input name="policyPageUpdated" required minLength={4} maxLength={100}/></label>:null}
|
||||
<label><input name="pictureAudioReviewed" type="checkbox" required/>我已连续观看全部选段并听取原声,不是只看代表帧</label>
|
||||
{stage==="plan"?<>
|
||||
<label><input name="referenceReviewed" type="checkbox" required/>已完整观看并听过全部候选参考,核实短剧营销来源、A/B 证据及成人内容适用性;没有仅凭搜索摘要通过的候选</label>
|
||||
<label><input name="storyReviewed" type="checkbox" required/>已核对所有因果、对白、动作反应和未揭晓信息,10 秒结尾完整</label>
|
||||
<label><input name="packagingReviewed" type="checkbox" required/>已核对最终英文字幕;本次没有新增封面、叠字、CTA 或广告文案</label>
|
||||
</>:null}
|
||||
<label><input name="noUnresolvedFindings" type="checkbox" required/>本次所选内容没有未解决的成人内容疑点;不代表平台批准</label>
|
||||
{(stage==="selection"?selected.segments.map(s=>s.id):plan?.segmentIds??[]).map(id=><fieldset key={id}><legend>选段 {id} 审核证据(每项至少 20 字符)</legend>
|
||||
{stage==="selection"?<><label>连续画面、服装与互动观察<textarea name={`${id}-picture`} required minLength={20} maxLength={2000}/></label><label>原声对白与声音观察<textarea name={`${id}-audio`} required minLength={20} maxLength={2000}/></label><label>字幕及其结合语境<textarea name={`${id}-subtitle`} required minLength={20} maxLength={2000}/></label></>:<label>参考依据、完整因果、字幕、切点及结尾观察<textarea name={`${id}-observation`} required minLength={20} maxLength={3000}/></label>}
|
||||
</fieldset>)}
|
||||
<button type="submit" className="secondary-button" disabled={busy}>保存本版本的人工审核记录</button>
|
||||
</form>:null}
|
||||
{data.preview&&allowedVideo(data.preview.url,"plans")?<div>
|
||||
<h3>REVIEW 预览 · {data.preview.duration} 秒</h3>
|
||||
<video aria-label="REVIEW 视频预览" src={data.preview.url} controls playsInline preload="metadata"/>
|
||||
<p>技术 QA:{data.preview.machinePass?"脚本通过":"存在未通过项目"}。尚未完成最终带声音审片,不是最终审核 PASS。</p>
|
||||
<details><summary>文件校验信息</summary><pre>{JSON.stringify({sha256:data.preview.sha256,checks:data.preview.checks},null,2)}</pre></details>
|
||||
<a href={`${data.preview.url}?download=1`} download>下载 REVIEW MP4</a>
|
||||
</div>:null}
|
||||
</>:null}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type ImageVideoTask = { id: string; title: string; prompt: string; durationSeconds: number; state: string; referenceUrl: string; videoUrl?: string; jobId?: string; error?: string; validation?: { videoSeconds?: number; width?: number; height?: number; fps?: number } };
|
||||
export type ImageVideoTask = { id: string; revision?: number; title: string; prompt: string; durationSeconds: number; state: string; referenceUrl: string; videoUrl?: string; jobId?: string; error?: string; validation?: { videoSeconds?: number; width?: number; height?: number; fps?: number } };
|
||||
export function isImageVideoTask(value: unknown): value is ImageVideoTask {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const task = value as Partial<ImageVideoTask>;
|
||||
@@ -27,10 +27,13 @@ async function jsonRequest(url: string, init?: RequestInit): Promise<Record<stri
|
||||
return await response.json() as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ImageVideoPanel({ sessionId, onDraft }: { sessionId?: string; onDraft?: (task: ImageVideoTask) => void }) {
|
||||
export function ImageVideoPanel({ sessionId, onDraft, disabled = false }: { sessionId?: string; onDraft?: (task: ImageVideoTask) => void; disabled?: boolean }) {
|
||||
const [tasks, setTasks] = useState<ImageVideoTask[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string>();
|
||||
const [file, setFile] = useState<File>(); const [title, setTitle] = useState(""); const [prompt, setPrompt] = useState(""); const [duration, setDuration] = useState(10); const [saving, setSaving] = useState(false);
|
||||
const [reference, setReference] = useState<string>(); const mounted = useRef(true);
|
||||
const [continuing, setContinuing] = useState<ImageVideoTask>(); const [confirmed, setConfirmed] = useState(false); const [resuming, setResuming] = useState(false);
|
||||
const continuationEpoch = useRef(0);
|
||||
useEffect(() => { continuationEpoch.current++; setContinuing(undefined); setConfirmed(false); setResuming(false); return () => { continuationEpoch.current++; }; }, [sessionId]);
|
||||
useEffect(() => {
|
||||
mounted.current = true; const controller = new AbortController(); let active = false;
|
||||
const refresh = async () => { if (active) return; active = true; try { const data = await jsonRequest("/api/image-video/tasks", { signal: controller.signal }); if (!controller.signal.aborted) { setTasks(Array.isArray(data.tasks) ? data.tasks.filter(isImageVideoTask) : []); setError(undefined); } } catch { if (!controller.signal.aborted) setError("无法读取任务历史,请检查本机服务。"); } finally { active = false; if (!controller.signal.aborted) setLoading(false); } };
|
||||
@@ -52,8 +55,26 @@ export function ImageVideoPanel({ sessionId, onDraft }: { sessionId?: string; on
|
||||
if (mounted.current) { setTasks(current => [task, ...current]); onDraft?.(task); }
|
||||
} catch (error) { if (mounted.current) setError(error instanceof Error ? error.message : "准备任务失败。"); } finally { if (mounted.current) setSaving(false); }
|
||||
};
|
||||
const continueTask = async () => {
|
||||
if (!sessionId || !continuing || !confirmed || disabled || resuming) return;
|
||||
const epoch = continuationEpoch.current;
|
||||
const current = () => mounted.current && continuationEpoch.current === epoch;
|
||||
setResuming(true); setError(undefined);
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/image-video/tasks/${continuing.id}/continue`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmed: true, expectedRevision: continuing.revision }) });
|
||||
const result = await response.json() as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
const code = (result.error as { code?: string } | undefined)?.code;
|
||||
throw new Error(code === "I2V_OWNER_ACTIVE" ? "原会话仍有效,请回原页面继续。" : code === "I2V_STALE_REVISION" ? "任务状态已更新,请取消后重新核对。" : "接续失败,请检查连接和任务状态;没有重新上传或生成。");
|
||||
}
|
||||
if (!isImageVideoTask(result.task) || result.task.id !== continuing.id) throw new Error("接续响应无效,请核实原任务。");
|
||||
const resumed = result.task;
|
||||
if (current()) { setTasks(tasks => tasks.map(task => task.id === resumed.id ? resumed : task)); setContinuing(undefined); setConfirmed(false); onDraft?.(resumed); }
|
||||
} catch (error) { if (current()) setError(error instanceof Error ? error.message : "接续失败。"); }
|
||||
finally { if (current()) setResuming(false); }
|
||||
};
|
||||
return <section className="i2v-panel" aria-label="图生视频工作区">
|
||||
<h2>图片 → 有声视频</h2><p>单张参考图,H3 ref2va。无需 CMS。默认竖屏、自然速度、有环境声;10 秒是目标,实际支持尚待验证。</p>
|
||||
<h2>图片 → 有声视频</h2><p>单张参考图,H3 ref2va。无需 CMS。默认竖屏、自然速度、10 秒;成片自动校验时长,少量超长尾帧会原速裁齐。声音与画面效果需检查。</p>
|
||||
{sessionId && onDraft ? <form onSubmit={event => { event.preventDefault(); void create(); }}>
|
||||
<label>参考图片<input aria-label="参考图片" type="file" accept="image/png,image/jpeg,image/webp" disabled={saving} onChange={event => setFile(event.currentTarget.files?.[0])} /></label>
|
||||
{reference ? <img className="i2v-reference" src={reference} alt="本机参考图片预览" /> : null}
|
||||
@@ -66,7 +87,16 @@ export function ImageVideoPanel({ sessionId, onDraft }: { sessionId?: string; on
|
||||
{loading ? <p role="status">正在读取持久任务…</p> : null}
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{!loading && tasks.length === 0 ? <p>尚无图生视频任务。</p> : null}
|
||||
<div className="i2v-history">{tasks.map(task => <ImageVideoTaskCard key={task.id} task={task} />)}</div>
|
||||
<div className="i2v-history">{tasks.map(task => <div key={task.id}>
|
||||
<ImageVideoTaskCard task={task} />
|
||||
{sessionId && onDraft && ["draft", "awaiting_submission"].includes(task.state) && Number.isSafeInteger(task.revision) ? <button type="button" className="secondary-button" disabled={disabled || resuming} onClick={() => { setContinuing(task); setConfirmed(false); setError(undefined); }}>继续此任务</button> : null}
|
||||
{continuing?.id === task.id ? <section className="i2v-continuation" aria-label={`接续 ${task.title}`}>
|
||||
<p>仅接续原任务到当前会话,保留图片、提示词、时长和上传结果。不重新上传,不自动生成;后续生成仍需单独审批。</p>
|
||||
<label><input type="checkbox" checked={confirmed} disabled={resuming} onChange={event => setConfirmed(event.currentTarget.checked)} />确认将此任务接续到当前会话</label>
|
||||
<button type="button" className="secondary-button" disabled={!confirmed || resuming || disabled} onClick={() => { void continueTask(); }}>{resuming ? "接续中…" : "确认接续(不生成)"}</button>
|
||||
<button type="button" className="secondary-button" disabled={resuming} onClick={() => { setContinuing(undefined); setConfirmed(false); }}>取消</button>
|
||||
</section> : null}
|
||||
</div>)}</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import {EditorWorkflowPanel} from "./EditorWorkflowPanel.js";
|
||||
import { ImageVideoTaskCard, isImageVideoTask } from "./ImageVideoPanel.js";
|
||||
|
||||
export function ToolFallback({ toolName, status }: ToolCallMessagePartProps) {
|
||||
export function ToolFallback({ toolName, status, result }: ToolCallMessagePartProps) {
|
||||
const report = toolName === "generate_minis_report" && typeof result === "object" && result !== null ? result as { files?: unknown; warning?: unknown } : undefined;
|
||||
const files = Array.isArray(report?.files) ? report.files.filter(isReportFile) : [];
|
||||
const media = ["prepare_drama_sources", "editor_job_status", "read_drama_frames"].includes(toolName) ? mediaResult(result) : undefined;
|
||||
const workflow=typeof result==="object"&&result!==null?result as {workflowUrl?:unknown}:undefined;
|
||||
return (
|
||||
<section className="tool-card" aria-label={`${toolName} 工具`}>
|
||||
<div className="tool-card__head">
|
||||
@@ -9,10 +15,48 @@ export function ToolFallback({ toolName, status }: ToolCallMessagePartProps) {
|
||||
<div><span className="eyebrow">TOOL</span><strong>{safeName(toolName)}</strong></div>
|
||||
<span className="tool-status">{status?.type === "complete" ? "完成" : status?.type === "incomplete" ? "失败" : "运行中"}</span>
|
||||
</div>
|
||||
{files.length === 0 ? null : <div className="report-downloads">{files.map((file) => <a className="secondary-button" key={file.url} href={file.url} download>下载 {file.format.toUpperCase()}</a>)}</div>}
|
||||
{toolName.startsWith("i2v_") && isImageVideoTask(result) ? <ImageVideoTaskCard task={result} /> : null}
|
||||
{typeof report?.warning === "string" ? <p role="status">{report.warning}</p> : null}
|
||||
{typeof workflow?.workflowUrl==="string"?<EditorWorkflowPanel url={workflow.workflowUrl}/>:null}
|
||||
{media ? <div>
|
||||
<p><strong>{media.title}</strong> · {media.state}</p>
|
||||
<code>{media.jobId}</code>
|
||||
<p>仅代表帧与素材准备;尚未完成剧情验证、内容审核或最终成片。</p>
|
||||
<div className="editor-frames">{media.frames.map(frame => <figure key={frame.url}>
|
||||
<img src={frame.url} alt={`第 ${String(frame.episodeNo)} 集 ${String(frame.time)} 秒代表帧`} loading="lazy" />
|
||||
<figcaption>第 {frame.episodeNo} 集 · {frame.time} s</figcaption>
|
||||
</figure>)}</div>
|
||||
</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function mediaResult(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
const job = value as { jobId?: unknown; title?: unknown; state?: unknown; sources?: unknown };
|
||||
if (typeof job.jobId !== "string" || typeof job.title !== "string" || typeof job.state !== "string" || !Array.isArray(job.sources)) return undefined;
|
||||
const frames: { episodeNo: number; time: number; url: string }[] = [];
|
||||
for (const value of job.sources.slice(0, 5)) {
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
const source = value as { episodeNo?: unknown; frames?: unknown };
|
||||
if (typeof source.episodeNo !== "number" || !Array.isArray(source.frames)) continue;
|
||||
for (const value of source.frames.slice(0, 3)) {
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
const frame = value as { time?: unknown; url?: unknown };
|
||||
if (typeof frame.time !== "number" || typeof frame.url !== "string" || !/^\/api\/sessions\/ses_[\w-]+\/editor\/job_[\w-]+\/frames\/frame_[\w-]+$/.test(frame.url)) continue;
|
||||
frames.push({ episodeNo: source.episodeNo, time: frame.time, url: frame.url });
|
||||
}
|
||||
}
|
||||
return { jobId: job.jobId, title: job.title, state: job.state, frames };
|
||||
}
|
||||
|
||||
function isReportFile(value: unknown): value is { format: string; url: string } {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const file = value as { format?: unknown; url?: unknown };
|
||||
return typeof file.format === "string" && ["html", "pdf", "json"].includes(file.format) && typeof file.url === "string" && /^\/api\/sessions\/ses_[\w-]+\/reports\/rpt_[\w-]+\/(html|pdf|json)$/.test(file.url);
|
||||
}
|
||||
|
||||
function safeName(name: string): string {
|
||||
return name.slice(0, 80).replace(/[^a-zA-Z0-9._-]/g, "?");
|
||||
}
|
||||
|
||||
@@ -154,6 +154,12 @@ export function reduceHarnessEvent(state: HarnessClientState, event: HarnessEven
|
||||
return updateMessageForRun(next, event.runId, { status: "failed", error: event.payload.message });
|
||||
}
|
||||
case "tool.requested": {
|
||||
if (event.runId !== undefined && !Object.values(next.messages).some((message) => message.role === "assistant" && message.runId === event.runId)) {
|
||||
next = upsertMessage(next, {
|
||||
id: `msg_${event.runId}`, role: "assistant", runId: event.runId,
|
||||
text: "", status: "running", attachmentIds: [],
|
||||
});
|
||||
}
|
||||
const tool: ClientTool = {
|
||||
toolCallId: event.payload.toolCallId,
|
||||
name: event.payload.toolName,
|
||||
|
||||
@@ -135,3 +135,43 @@
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } }
|
||||
@media (max-width: 720px) { .topbar { grid-template-columns: 1fr auto; padding: 0 12px; } .topbar__actions { grid-column: 2; } .thread-viewport { padding: 20px 16px; } .composer-area { padding: 0 10px 10px; } .tool-summary { grid-template-columns: repeat(2, 1fr); } .tool-summary > div + div { border-left: 0; } .settings-form { grid-template-columns: 1fr; } .settings-form > * { grid-column: 1; } .settings-actions { flex-wrap: wrap; } }
|
||||
.approval-arguments { max-height: 240px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; text-align: left; }
|
||||
.report-downloads { display: flex; gap: 8px; flex-wrap: wrap; padding-top: 12px; }
|
||||
.cms-panel { padding: 16px; border-top: 1px solid var(--border); }
|
||||
.cms-panel__body { display: grid; gap: 12px; margin-top: 12px; font-size: 13px; }
|
||||
.cms-panel__body form, .cms-panel__body label { display: grid; gap: 8px; }
|
||||
.cms-panel__body input { min-width: 0; width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; }
|
||||
.cms-drama-list { list-style: none; padding: 0; display: grid; gap: 12px; }
|
||||
.cms-drama-list li { display: grid; gap: 6px; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; }
|
||||
.editor-frames { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.editor-frames figure { margin: 0; min-width: 0; }
|
||||
.editor-frames img { display: block; width: 100%; max-height: 190px; object-fit: contain; background: #111; border-radius: 6px; }
|
||||
.editor-frames figcaption { font-size: 11px; text-align: center; margin-top: 4px; }
|
||||
.editor-workflow { margin-top: 12px; display: grid; gap: 12px; }
|
||||
.editor-workflow video { display: block; width: min(100%, 270px); max-height: 480px; background: #111; border-radius: 10px; }
|
||||
.editor-workflow form, .editor-workflow fieldset { display: grid; gap: 12px; min-width: 0; }
|
||||
.editor-workflow label { display: grid; gap: 6px; }
|
||||
.editor-workflow label:has(input[type="checkbox"]) { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.editor-workflow input:not([type="checkbox"]), .editor-workflow textarea { width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid #ccd2dc; border-radius: 6px; background: white; color: #192233; }
|
||||
.editor-workflow textarea { min-height: 72px; }
|
||||
.editor-workflow pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 360px; overflow: auto; }
|
||||
.editor-review-segment { margin-block: 16px; }
|
||||
.i2v-panel { box-sizing: border-box; min-width: 0; width: 100%; max-width: 100vw; padding: 18px; border-bottom: 1px solid var(--border); }
|
||||
.i2v-panel h2 { font-size: 18px; margin: 0 0 10px; }
|
||||
.i2v-panel p { font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.i2v-panel form { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; gap: 10px; margin: 16px 0; }
|
||||
.i2v-panel label { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; gap: 5px; font-size: 12px; }
|
||||
.i2v-panel input, .i2v-panel textarea { width: 100%; min-width: 0; padding: 8px; border: 1px solid #d6dbe1; border-radius: 7px; background: #fff; color: #182431; }
|
||||
.i2v-panel textarea { min-height: 90px; resize: vertical; }
|
||||
.i2v-reference { width: 88px; height: 116px; object-fit: contain; border-radius: 6px; background: #f0f2f4; }
|
||||
.i2v-history { display: grid; gap: 14px; }
|
||||
.i2v-continuation { margin-top: 10px; padding: 10px; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.i2v-continuation label { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 10px; }
|
||||
.i2v-continuation input[type="checkbox"] { width: auto; margin-top: 2px; }
|
||||
.i2v-continuation button { margin: 0 8px 6px 0; }
|
||||
.i2v-task { display: flex; gap: 12px; padding: 12px 0; min-width: 0; }
|
||||
.i2v-task > div { min-width: 0; flex: 1; }
|
||||
.i2v-task h3 { margin: 0 0 6px; font-size: 14px; }
|
||||
.i2v-task p, .i2v-task code { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.i2v-task video { display: block; width: 100%; max-height: 440px; margin: 12px 0; background: #15191e; border-radius: 8px; }
|
||||
.approval-dialog:has(.i2v-task) { max-height: 90vh; overflow-y: auto; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.app-shell { height: 100dvh; min-height: 620px; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
|
||||
.workspace-grid { display: grid; grid-template-columns: minmax(220px, 272px) minmax(420px, 1fr) minmax(260px, 330px); min-height: 0; }
|
||||
.workspace-grid > div { min-width: 0; min-height: 0; }
|
||||
#skills-panel { overflow-y: auto; overscroll-behavior-y: contain; scrollbar-gutter: stable; }
|
||||
.workspace-grid > div + div { border-left: 1px solid var(--border); }
|
||||
.side-panel, .chat-panel { height: 100%; min-height: 0; }
|
||||
.side-panel { display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
|
||||
|
||||
@@ -35,6 +35,44 @@ function makeStore() {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("App", () => {
|
||||
it("fills a generation-approval prompt for a continued uploaded task without sending or asking for another upload", async () => {
|
||||
const store = makeStore();
|
||||
store.getSnapshot().skills.push({ name: "swads-image-to-video", description: "Image video", source: "local", filePath: "external/swads-image-to-video/SKILL.md", diagnostics: [] });
|
||||
const id = "i2v_11111111-1111-1111-1111-111111111111";
|
||||
const task = { id, revision: 4, title: "Cat", prompt: "Cat blinks", state: "awaiting_submission", durationSeconds: 3, referenceUrl: `/api/image-video/tasks/${id}/reference` };
|
||||
vi.stubGlobal("fetch", (_url: string, init?: RequestInit) => Promise.resolve(new Response(JSON.stringify(init?.method === "POST" ? { task } : { tasks: [task] }))));
|
||||
render(<App store={store} autoStart={false} modelConfigured />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: "继续此任务" }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /确认将此任务接续到当前会话/ }));
|
||||
await user.click(screen.getByRole("button", { name: "确认接续(不生成)" }));
|
||||
const composer = screen.getByRole<HTMLTextAreaElement>("textbox", { name: "消息" });
|
||||
expect(composer.value).toContain(id);
|
||||
expect(composer.value).toContain("i2v_submit");
|
||||
expect(composer.value).not.toContain("i2v_upload_reference");
|
||||
expect(composer.value).toContain("3 秒");
|
||||
expect(store.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
it("lets the user select a discovered Skill without submitting data immediately", async () => {
|
||||
const store = makeStore();
|
||||
render(<App store={store} autoStart={false} modelConfigured />);
|
||||
await userEvent.setup().click(screen.getByRole("button", { name: "使用 ads-analysis" }));
|
||||
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toContain("ads-analysis");
|
||||
expect(store.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns to Chat after choosing a Skill in the mobile layout", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
|
||||
const store = makeStore();
|
||||
render(<App store={store} autoStart={false} modelConfigured />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("tab", { name: "Skills" }));
|
||||
await user.click(screen.getByRole("button", { name: "使用 ads-analysis" }));
|
||||
expect(screen.getByRole("tab", { name: "Chat" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toContain("ads-analysis");
|
||||
expect(store.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the three primary work areas", () => {
|
||||
render(<App store={makeStore()} autoStart={false} modelConfigured />);
|
||||
expect(screen.getByRole("region", { name: "Skills Catalog" })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CmsPanel } from "../src/components/cms/CmsPanel.js";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
describe("CMS panel", () => {
|
||||
it("clears passwords after failed login and never renders reflected upstream secrets", async () => {
|
||||
vi.stubGlobal("fetch", (_url: string, init?: RequestInit) => Promise.resolve(new Response(JSON.stringify(init?.method === "POST" ? { error: { code: "CMS_LOGIN_FAILED", message: "echo password-secret" } } : { connection: { configured: true, connected: false } }), { status: init?.method === "POST" ? 400 : 200 })));
|
||||
render(<CmsPanel sessionId="ses_1" busy={false} onSelect={() => undefined} />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "CMS 连接与选剧" }));
|
||||
expect(screen.getByText(/选段审核和 10 秒 REVIEW 预览/)).toBeVisible();
|
||||
await user.type(screen.getByLabelText("CMS 邮箱"), "fixture@example.com");
|
||||
await user.type(screen.getByLabelText("CMS 密码"), "password-secret");
|
||||
await user.click(screen.getByRole("button", { name: "登录 CMS" }));
|
||||
expect(screen.getByLabelText("CMS 密码")).toHaveValue("");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("登录失败");
|
||||
expect(screen.queryByText(/password-secret/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import {render,screen} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {it,expect,vi,afterEach} from "vitest";
|
||||
import {EditorWorkflowPanel} from "../src/components/tools/EditorWorkflowPanel.js";
|
||||
const url="/api/sessions/ses_1/editor/job_1/workflow";
|
||||
afterEach(()=>vi.unstubAllGlobals());
|
||||
it("shows the actual registered preview with REVIEW labeling and no auto approval",async()=>{
|
||||
vi.stubGlobal("fetch",()=>Promise.resolve({ok:true,json:()=>Promise.resolve({jobId:"job_1",selection:null,plan:null,preview:{url:"/api/sessions/ses_1/editor/job_1/plans/plan_1/preview",duration:10,reviewStatus:"REVIEW",sha256:"abc",machinePass:true}})}));
|
||||
render(<EditorWorkflowPanel url={url}/>);
|
||||
await userEvent.click(screen.getByRole("button",{name:"打开剪辑审核与预览"}));
|
||||
expect(await screen.findByText(/10 秒/)).toBeVisible();
|
||||
expect(screen.getByLabelText("REVIEW 视频预览")).toHaveAttribute("src","/api/sessions/ses_1/editor/job_1/plans/plan_1/preview");
|
||||
expect(screen.getByRole("link",{name:"下载 REVIEW MP4"})).toHaveAttribute("href","/api/sessions/ses_1/editor/job_1/plans/plan_1/preview?download=1");
|
||||
expect(screen.queryByText("最终审核 PASS")).not.toBeInTheDocument();
|
||||
});
|
||||
it("keeps human review boxes unchecked and rejects off-job media URLs",async()=>{
|
||||
vi.stubGlobal("fetch",()=>Promise.resolve({ok:true,json:()=>Promise.resolve({jobId:"job_1",selection:{hash:"abc",reviewed:false,segments:[{id:"one",episodeNo:1,start:0,end:10,videoUrl:"https://external.example/secret",reason:"Test",subtitles:"Test"}]},plan:null,preview:null})}));
|
||||
render(<EditorWorkflowPanel url={url}/>);await userEvent.click(screen.getByRole("button",{name:"打开剪辑审核与预览"}));
|
||||
for(const checkbox of await screen.findAllByRole("checkbox"))expect(checkbox).not.toBeChecked();
|
||||
expect(screen.queryByLabelText("选段 one 原声视频")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -9,6 +9,42 @@ const task = { id, title: "Window", prompt: "Turn naturally toward the window; a
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
|
||||
|
||||
describe("image video UI", () => {
|
||||
it("continues the exact task only after confirmation and never sends or generates automatically", async () => {
|
||||
const onDraft = vi.fn(); const requests: { url: string; body: unknown }[] = [];
|
||||
const pending = { ...task, state: "awaiting_submission", revision: 9 };
|
||||
vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") requests.push({ url, body: init.body });
|
||||
return Promise.resolve(new Response(JSON.stringify(init?.method === "POST" ? { task: { ...pending, revision: 10 } } : { tasks: [pending] })));
|
||||
});
|
||||
render(<ImageVideoPanel sessionId="ses_new" onDraft={onDraft} />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: "继续此任务" }));
|
||||
expect(screen.getByRole("button", { name: "确认接续(不生成)" })).toBeDisabled();
|
||||
expect(requests).toEqual([]);
|
||||
await user.click(screen.getByRole("checkbox", { name: /确认将此任务接续到当前会话/ }));
|
||||
await user.click(screen.getByRole("button", { name: "确认接续(不生成)" }));
|
||||
await waitFor(() => expect(onDraft).toHaveBeenCalledWith(expect.objectContaining({ id, state: "awaiting_submission", revision: 10 })));
|
||||
expect(requests).toEqual([{ url: `/api/sessions/ses_new/image-video/tasks/${id}/continue`, body: JSON.stringify({ confirmed: true, expectedRevision: 9 }) }]);
|
||||
});
|
||||
it("does not offer continuation for unknown submission/upload outcomes", async () => {
|
||||
vi.stubGlobal("fetch", () => Promise.resolve(new Response(JSON.stringify({ tasks: [{ ...task, revision: 2, state: "unknown_upload" }] }))));
|
||||
render(<ImageVideoPanel sessionId="ses_new" onDraft={vi.fn()} />);
|
||||
expect(await screen.findByText(/上传状态未知/)).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "继续此任务" })).not.toBeInTheDocument();
|
||||
});
|
||||
it("ignores a late continuation response after switching sessions", async () => {
|
||||
const onDraft = vi.fn(); const pending = { ...task, revision: 9, state: "awaiting_submission" };
|
||||
let finish!: (response: Response) => void;
|
||||
vi.stubGlobal("fetch", (_url: string, init?: RequestInit) => init?.method === "POST" ? new Promise<Response>(resolve => { finish = resolve; }) : Promise.resolve(new Response(JSON.stringify({ tasks: [pending] }))));
|
||||
const view = render(<ImageVideoPanel sessionId="ses_old" onDraft={onDraft} />); const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: "继续此任务" }));
|
||||
await user.click(screen.getByRole("checkbox", { name: /确认将此任务接续到当前会话/ }));
|
||||
await user.click(screen.getByRole("button", { name: "确认接续(不生成)" }));
|
||||
view.rerender(<ImageVideoPanel sessionId="ses_new" onDraft={onDraft} />);
|
||||
await act(() => { finish(new Response(JSON.stringify({ task: pending }))); return Promise.resolve(); });
|
||||
expect(onDraft).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "继续此任务" })).toBeEnabled();
|
||||
});
|
||||
it("restores queued tasks, polls at 30 seconds, and cleans up after unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetcher = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ tasks: [task] }))));
|
||||
|
||||
@@ -16,6 +16,17 @@ function event<T extends HarnessEvent>(value: Omit<T, "eventId" | "sessionId" |
|
||||
}
|
||||
|
||||
describe("reduceHarnessEvent", () => {
|
||||
it("shows tool-only assistant turns before the first text token without duplicating the final message", () => {
|
||||
const pending = [
|
||||
event<HarnessEvent>({ type: "run.started", sequence: 1, runId: "run_1", payload: { modelId: "qwen" } }),
|
||||
event<HarnessEvent>({ type: "tool.requested", sequence: 2, runId: "run_1", payload: { toolCallId: "tool_1", toolName: "mock_ads_metrics", arguments: { accountId: "demo-account", days: 7 } } }),
|
||||
].reduce(reduceHarnessEvent, createInitialHarnessState());
|
||||
expect(pending.messages.msg_run_1).toMatchObject({ role: "assistant", runId: "run_1", status: "running", text: "" });
|
||||
const final = reduceHarnessEvent(pending, event<HarnessEvent>({ type: "assistant.delta", sequence: 3, runId: "run_1", payload: { messageId: "msg_run_1", delta: "已获取指标" } }));
|
||||
expect(final.messageOrder).toEqual(["msg_run_1"]);
|
||||
expect(final.messages.msg_run_1?.text).toBe("已获取指标");
|
||||
});
|
||||
|
||||
it("builds messages, run state, tools, approvals, and timeline from ordered events", () => {
|
||||
const events: HarnessEvent[] = [
|
||||
event({ type: "user.message", sequence: 1, payload: { messageId: "msg_user", text: "分析素材", attachmentIds: [] } }),
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ApprovalDialog } from "../src/components/approvals/ApprovalDialog.js";
|
||||
import { AdsMetricsTool } from "../src/components/tools/AdsMetricsTool.js";
|
||||
import { ToolFallback } from "../src/components/tools/ToolFallback.js";
|
||||
import type { ClientApproval } from "../src/store/reducer.js";
|
||||
|
||||
const pendingApproval: ClientApproval = {
|
||||
@@ -16,6 +17,49 @@ const pendingApproval: ClientApproval = {
|
||||
};
|
||||
|
||||
describe("tool and approval UI", () => {
|
||||
it("shows registered representative frames but never external frame URLs or a fake final pass", () => {
|
||||
render(<ToolFallback type="tool-call" toolName="prepare_drama_sources" toolCallId="tool_media" args={{}} argsText="" result={{ jobId: "job_known", title: "Test drama", state: "sources_ready", sources: [{ episodeNo: 1, duration: 72.3, frames: [
|
||||
{ frameId: "frame_known", time: 10.5, url: "/api/sessions/ses_1/editor/job_known/frames/frame_known" },
|
||||
{ frameId: "frame_bad", time: 22, url: "https://external.example/image.jpg" },
|
||||
] }] }} status={{ type: "complete" }} addResult={vi.fn()} resume={vi.fn()} respondToApproval={vi.fn()} />);
|
||||
expect(screen.getAllByRole("img")).toHaveLength(1);
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/sessions/ses_1/editor/job_known/frames/frame_known");
|
||||
expect(screen.getByText(/尚未完成剧情验证/)).toBeVisible();
|
||||
expect(screen.getByText("job_known")).toBeVisible();
|
||||
});
|
||||
it.each([
|
||||
["prepare_drama_sources", "允许下载所选分集并在本机抽帧吗?", "批准本次下载"],
|
||||
["read_drama_frames", "允许将字幕和代表帧发送给当前模型吗?", "批准本次分析"],
|
||||
["render_editor_preview", "允许生成 10 秒 REVIEW 预览吗?", "批准本次渲染"],
|
||||
])("explains the media boundary for %s", (toolName, title, button) => {
|
||||
render(<ApprovalDialog request={{ ...pendingApproval, toolName, arguments: { jobId: "job_authorized" } }} onApprove={vi.fn()} onDeny={vi.fn()} />);
|
||||
expect(screen.getByRole("dialog", { name: title })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: button })).toBeVisible();
|
||||
expect(screen.getByText(/job_authorized/)).toBeVisible();
|
||||
expect(screen.queryByText("report.md")).not.toBeInTheDocument();
|
||||
});
|
||||
it("identifies licensed CMS content reads and their model destination", () => {
|
||||
render(<ApprovalDialog request={{ ...pendingApproval, toolName: "cms_drama_inventory", arguments: { dramaId: "fixture-drama" } }} onApprove={vi.fn()} onDeny={vi.fn()} />);
|
||||
expect(screen.getByRole("dialog", { name: "允许读取 CMS 内容并交给模型吗?" })).toBeVisible();
|
||||
expect(screen.getByText(/fixture-drama/)).toBeVisible();
|
||||
expect(screen.queryByText("report.md")).not.toBeInTheDocument();
|
||||
});
|
||||
it("offers only Session report download links and exposes PDF generation failures", () => {
|
||||
render(<ToolFallback type="tool-call" toolName="generate_minis_report" toolCallId="tool_report" args={{}} argsText="" result={{ pdfStatus: "failed", warning: "PDF 未生成", files: [{ format: "html", url: "/api/sessions/ses_1/reports/rpt_1/html" }, { format: "pdf", url: "https://untrusted.example/private" }] }} status={{ type: "complete" }} addResult={vi.fn()} resume={vi.fn()} respondToApproval={vi.fn()} />);
|
||||
expect(screen.getByRole("link", { name: "下载 HTML" })).toHaveAttribute("href", "/api/sessions/ses_1/reports/rpt_1/html");
|
||||
expect(screen.queryByRole("link", { name: "下载 PDF" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("PDF 未生成")).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows actual analytics scope and destination rather than pretending the action only saves a file", () => {
|
||||
render(<ApprovalDialog request={{ ...pendingApproval, toolName: "metrics_semantic_query", arguments: { date_from: "2026-09-08", date_to: "2026-09-08", external_account_id: "test-account", metrics: ["spend"] } }} onApprove={vi.fn()} onDeny={vi.fn()} />);
|
||||
expect(screen.getByRole("dialog", { name: "允许查询真实经营数据吗?" })).toBeVisible();
|
||||
expect(screen.getByText(/2026-09-08/)).toBeVisible();
|
||||
expect(screen.getByText(/test-account/)).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "批准本次查询" })).toBeVisible();
|
||||
expect(screen.queryByText("report.md")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables both decisions after submitting approval", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveApproval: (() => void) | undefined;
|
||||
|
||||
@@ -9,8 +9,9 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:3001",
|
||||
"/health": "http://127.0.0.1:3001"
|
||||
// Preserve the page Host so the backend can verify Origin for CMS login.
|
||||
"/api": { target: "http://127.0.0.1:3001", changeOrigin: false },
|
||||
"/health": { target: "http://127.0.0.1:3001", changeOrigin: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user