feat: publish Agent Studio integrations and setup guide

This commit is contained in:
Wenxin Duan
2026-09-16 16:32:16 +08:00
parent 2ad089d68a
commit 05e6060490
106 changed files with 4942 additions and 198 deletions
+16
View File
@@ -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);
+2
View File
@@ -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;
+95 -7
View File
@@ -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 }),
};
+58
View File
@@ -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);
});
}
+48
View File
@@ -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);
});
}
+13
View File
@@ -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);
+20
View File
@@ -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);
});
}
+21
View File
@@ -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(); }
});
});
+37
View File
@@ -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});}
});
+34 -1
View File
@@ -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 {
+20
View File
@@ -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(); }
});
});
+6 -2
View File
@@ -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
View File
@@ -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>
+66
View File
@@ -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 -3
View File
@@ -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>;
}
+45 -1
View File
@@ -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, "?");
}
+6
View File
@@ -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,
+40
View File
@@ -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
View File
@@ -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); }
+38
View File
@@ -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();
+22
View File
@@ -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();
});
});
+21
View File
@@ -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();
});
+36
View File
@@ -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] }))));
+11
View File
@@ -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: [] } }),
+44
View File
@@ -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;
+3 -2
View File
@@ -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 }
}
}
});