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 -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); }