Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const capabilityCopy: Record<WorkspaceLocale, Record<string, LocalizedCopy>> = {
en: {
codex_chat: {displayName: "Codex Goal conversation defaults", description: "Choose the native Codex profile, model, speed tier and reasoning effort for new Goal conversations. Existing conversations keep their original history. This does not start or reconfigure an active Agent Loop, worker or steward."},
decision_context: {displayName: "Find evidence", description: "Find advisory evidence with a temporary private profile. OpenViking and BM25 are retrieval implementations; this page does not save a binding or enable automatic memory."},
content_ops: {displayName: "Reference styles", description: "Retrieve source styles from an existing private catalog and prepare attributed outlines. No source or material-store writes."},
content_ops: {displayName: "Content production", description: "Prepare source-backed outlines and local videos. Review the result before sharing; rendering uses an optional local extension."},
goal_storage: { displayName: "New Goal authority", description: "New Goals default to canonical SQLite with hard leases. Choose storage and execution policy for future Goals. Existing Goals require a separate backed-up migration." },
manager_runtime: {
displayName: "Runtime",
Expand Down Expand Up @@ -83,7 +83,7 @@ const capabilityCopy: Record<WorkspaceLocale, Record<string, LocalizedCopy>> = {
"zh-CN": {
codex_chat: {displayName: "Codex Goal 对话默认值", description: "分别选择 Codex 配置来源、模型、速度档和推理强度,作用于本机的新 Goal 对话。已有对话保留原历史;不会启动或重配正在运行的 Agent Loop、工作 Agent 或管家。"},
decision_context: {displayName: "资料召回", description: "通过临时私有配置查找参考证据。OpenViking、BM25 是检索实现;本页不保存绑定,也不启用自动记忆。"},
content_ops: {displayName: "素材与风格", description: "从原有私有目录检索表达结构,准备带来源署名的提纲;不执行来源或素材库写入。"},
content_ops: {displayName: "内容制作", description: "准备有来源的提纲与本机视频,检查作品后再分享;视频制作使用可选本机扩展。"},
goal_storage: { displayName: "新 Goal 的权威存储", description: "新 Goal 默认使用 canonical SQLite 与 hard lease;可选择之后新 Goal 的存储与执行策略。已有 Goal 仍需单独备份、迁移。" },
manager_runtime: {
displayName: "运行环境",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.content-production-panel {padding:0 24px 24px;}
.content-media {padding:16px 0 24px; border-bottom:1px solid var(--pw-line);}
.content-media-heading, .content-media-title {display:flex; justify-content:space-between; align-items:flex-start; gap:16px;}
.content-media h3, .content-media h4 {margin:0 0 6px; font-size:var(--text-lead); font-weight:600;}
.content-media p {margin:6px 0 14px; color:var(--pw-muted); font-size:var(--text-body); line-height:1.6;}
.content-media button {min-height:44px; border:1px solid var(--pw-line-strong); border-radius:6px; padding:8px 12px; background:var(--pw-card); color:inherit; cursor:pointer; font:inherit; font-size:var(--text-body);}
.content-media button:disabled {opacity:.5; cursor:default;}
.content-media button:focus-visible, .content-media a:focus-visible {outline:2px solid var(--pw-blue); outline-offset:3px;}
.content-media-notice {padding:10px 12px; background:var(--pw-hover); border-left:2px solid var(--pw-muted);}
.content-media-workspace {display:grid; grid-template-columns:minmax(130px, 180px) minmax(0, 1fr); gap:20px; margin-top:20px;}
.content-media nav {display:flex; flex-direction:column; gap:5px;}
.content-media nav button {text-align:left; border-color:transparent; display:grid; gap:6px;}
.content-media nav button[aria-pressed=true] {background:var(--pw-hover); border-color:var(--pw-line-strong);}
.content-media small {display:block; color:var(--pw-muted); font-size:var(--text-caption); line-height:1.5;}
.content-media-result {min-width:0; overflow-wrap:anywhere;}
.content-media-result video {display:block; width:100%; margin:14px 0; border-radius:8px; background:var(--pw-card);}
.content-media-timeline {list-style:none; padding:0; margin:14px 0; border-top:1px solid var(--pw-line);}
.content-media-timeline li {padding:12px 0; border-bottom:1px solid var(--pw-line); font-size:var(--text-body);}
.content-media-timeline small {margin-top:4px;}
.content-media-downloads {display:flex; gap:16px; flex-wrap:wrap; font-size:var(--text-body);}
.content-media details {font-size:var(--text-caption); margin-top:18px;}
.content-media details summary {cursor:pointer; color:var(--pw-muted);}
.content-media details li {line-height:1.6; margin:6px 0;}
.content-media-empty {padding:22px 0;}
@media(max-width:680px) {.content-production-panel {padding:0 16px 20px;}.content-media-workspace {grid-template-columns:minmax(0, 1fr);}.content-media nav {flex-direction:row; overflow-x:auto;}.content-media nav button {min-width:160px;}.content-media-heading, .content-media-title {flex-wrap:wrap;}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {useEffect, useRef, useState} from "react";
import {useWorkspaceI18n} from "./i18n";
import "./content-media-workbench.css";

type Provider = {status: string; enabled: boolean | null; next_action: string | null};
type Composition = {
composition_id: string; revision: number; status: string; title: string; duration_seconds: number;
shots: {title: string; seconds: number; kind: string; boundary: string; source_ref: string}[];
result: {files: Record<string, {sha256: string; bytes: number}>; qa: {frames: number; full_decode: string; narration: {mode: string}; blender_scenes?: {project_reopened: boolean}[]}; cost: {render_wall_seconds: number}} | null;
provider: Provider;
};
type Library = {ok: boolean; compositions: Composition[]; provider: Provider};
const ENDPOINT = "/api/chat/content-media";

export function ContentMediaWorkbench({goalId}: {goalId: string}) {
const {locale} = useWorkspaceI18n();
const copy = (cn: string, en: string) => locale === "zh-CN" ? cn : en;
const [library, setLibrary] = useState<Library | null>(null), [selectedId, setSelectedId] = useState("");
const [error, setError] = useState(""), [busy, setBusy] = useState(false);
const pending = useRef(false), generation = useRef(0), readSequence = useRef(0);
const selected = library?.compositions.find(item => item.composition_id === selectedId) ?? library?.compositions[0];
async function request(action: string, item?: Composition) {
const response = await fetch(ENDPOINT, {method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({goal_id: goalId, action, ...(item ? {composition_id: item.composition_id, expected_revision: item.revision} : {})})});
if (!response.ok) throw new Error();
return await response.json();
}
async function reload() {
const current = generation.current;
const sequence = ++readSequence.current;
try {
const value: Library = await request("list");
if (current === generation.current && sequence === readSequence.current) {setLibrary(value); setError("");}
} catch {if (current === generation.current && sequence === readSequence.current) setError(copy("作品暂不可读,请重新读取。", "Cannot read compositions. Recheck the workspace."));}
}
useEffect(() => {
generation.current += 1; setLibrary(null); setSelectedId(""); pending.current = false; setBusy(false); void reload();
return () => {generation.current += 1;};
}, [goalId]);
const running = library?.compositions.some(item => item.status === "rendering");
useEffect(() => {
if (!running) return;
const timer = setInterval(() => void reload(), 2000);
return () => clearInterval(timer);
}, [running, goalId]);
async function operate(action: "render" | "cancel") {
if (!selected || pending.current) return;
pending.current = true; setBusy(true); setError("");
const current = generation.current;
try {
const value = await request(action, selected);
if (current !== generation.current) return;
await reload();
if (!value.ok) setError(copy("渲染器未就绪。请启用已验证的本机视频扩展后重试。", "Renderer unavailable. Enable the verified local video extension, then retry."));
} catch {if (current === generation.current) setError(copy("操作未确认。先重新读取,避免重复启动。", "Operation unconfirmed. Recheck before retrying."));}
finally {if (current === generation.current) {pending.current = false; setBusy(false);}}
}
function file(name: string) {return `${ENDPOINT}/file?${new URLSearchParams({goal_id: goalId, composition_id: selected!.composition_id, file: name})}`;}
const status: Record<string, string> = {
prepared: copy("待制作", "Ready to render"), rendering: copy("正在制作", "Rendering"), ready: copy("作品已生成", "Video ready"),
failed: copy("制作失败,可修复后重试", "Render failed; repair and retry"), cancelled: copy("已取消,可重新制作", "Cancelled; ready to retry"),
interrupted: copy("上次制作中断,可重新制作", "Previous render interrupted; retry available"), damaged: copy("结果校验失败,请重新制作", "Result verification failed; render again"),
};
const providerReady = library?.provider.status === "ready";
const hasBlender = selected?.shots.some(shot => shot.kind === "blender");
return <section className="content-media" aria-label={copy("视频制作", "Video production")}>
<div className="content-media-heading"><div><h3>{copy("制作视频", "Produce a video")}</h3>
<p>{copy("让 Agent 准备脚本、来源和时间线,在这里制作并查看作品。", "Have your Agent prepare the story, sources and timeline. Render and review the result here.")}</p></div>
<button type="button" onClick={() => void reload()} disabled={busy}>{copy("重新读取", "Recheck")}</button></div>
{error ? <p role="alert">{error}</p> : null}
{!library && !error ? <p role="status">{copy("正在读取作品…", "Loading compositions…")}</p> : null}
{library && !providerReady ? <p className="content-media-notice" role="status">{copy("本机视频扩展未就绪。作品与来源保留,安装并启用 loopx-media 后即可制作。", "The local video extension is unavailable. Prepared work is retained; install and enable loopx-media to render.")}</p> : null}
{library && !selected ? <p className="content-media-empty">{copy("还没有准备好的作品。把想讲的内容交给 Agent,它会通过 Content-Ops 保存可制作的脚本。", "No prepared compositions yet. Tell your Agent what to explain; it can save a renderable brief through Content-Ops.")}</p> : null}
{selected ? <div className="content-media-workspace">
<nav aria-label={copy("选择作品", "Choose a composition")}>{library!.compositions.map(item => <button key={item.composition_id} type="button" aria-pressed={item.composition_id === selected.composition_id}
onClick={() => setSelectedId(item.composition_id)}><span>{item.title}</span><small>{item.duration_seconds}s · {status[item.status] ?? item.status}</small></button>)}</nav>
<div className="content-media-result"><div className="content-media-title"><div><h4>{selected.title}</h4><p role="status">{status[selected.status] ?? selected.status} · {selected.duration_seconds}s · {copy("修订", "Revision")} {selected.revision}</p></div>
{selected.status === "rendering" ? <button type="button" disabled={busy} onClick={() => void operate("cancel")}>{copy("取消制作", "Cancel render")}</button>
: <button type="button" disabled={busy || !providerReady || selected.status === "ready"} onClick={() => void operate("render")}>{copy("制作视频", "Render video")}</button>}</div>
{hasBlender && selected.status !== "ready" ? <p>{copy("包含 Blender 三维场景。制作需要已安装的本机 Blender 扩展依赖;作品会保留可编辑工程与颜色、深度画面。", "Includes Blender scenes. Rendering needs the local Blender extra; the editable project, color and depth frames are retained.")}</p> : null}
{selected.status === "ready" && selected.result ? <><video key={`${selected.composition_id}:${selected.result.files["video.mp4"].sha256}`} controls preload="metadata" poster={file("poster.png")} src={file("video.mp4")}>
<track kind="captions" src={file("captions.vtt")} label={copy("字幕", "Captions")} default /></video>
<div className="content-media-downloads"><a href={file("video.mp4")} download="video.mp4">{copy("下载视频", "Download video")}</a><a href={file("captions.srt")} download="captions.srt">{copy("字幕", "Subtitles")}</a><a href={file("transcript.txt")} download="transcript.txt">{copy("文字稿", "Transcript")}</a>
{selected.result.files["blender-project.zip"] ? <a href={file("blender-project.zip")} download="blender-project.zip">{copy("Blender 工程与画面", "Blender project and passes")}</a> : null}</div>
<p>{copy("完整解码通过", "Full decode passed")} · {selected.result.qa.frames} {copy("帧", "frames")} · {copy("渲染耗时", "Render time")} {selected.result.cost.render_wall_seconds}s</p>
{selected.result.qa.blender_scenes?.length ? <p>{copy("三维工程已重新打开并检查,颜色与深度帧完整。", "The 3D project was reopened and checked; color and depth frames are complete.")}</p> : null}
</> : <ol className="content-media-timeline">{selected.shots.map((shot, i) => <li key={i}><span>{shot.title}</span><small>{shot.seconds}s · {shot.boundary}</small></li>)}</ol>}
{["failed", "damaged"].includes(selected.status) ? <p>{hasBlender ? copy("请让 Agent 检查 Blender 依赖、场景范围与制作时限。修复本机依赖后可重试;修改场景需保存新修订。", "Ask your Agent to check the Blender extra, scene bounds and render time. Retry after dependency repair; save a new revision for scene changes.") : copy("请让 Agent 检查素材、字体和配音时长;修改脚本后保存新修订,或修复本机依赖后重试。", "Ask your Agent to check sources, fonts and narration timing. Save a new revision for story changes, or repair the local dependencies and retry.")}</p> : null}
<details><summary>{copy("来源与检查", "Sources and checks")}</summary><ul>{selected.shots.map((shot, i) => <li key={i}>{shot.title} — {shot.boundary}<br /><small>{shot.source_ref}</small></li>)}</ul>
<p>{copy("本机私有作品。编码通过不代表创意质量、理解效果或发布许可;请观看并试听。", "Private local work. Encoding does not establish creative quality, audience understanding or publication approval. Watch and listen before sharing.")}</p></details>
</div></div> : null}
</section>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { localizeCapability, localizedCapabilityFieldCopy } from "./capability-l
import { orderCapabilitiesForPresentation, canEditCapability, CapabilityCatalogNavigation, CapabilityConfigurationSummary, CapabilityDetailHeader, CapabilityEditorStatus, CapabilityConfigurationHelp } from "./capability-workbench";
import { GoalAutoNotifyToggle } from "./notification-settings-panel";
import { ContentReferenceWorkbench } from "./content-reference-workbench";
import {ContentMediaWorkbench} from "./content-media-workbench";
import { DecisionContextWorkbench } from "./decision-context-workbench";
import { rewardMemoryBindingStatus, rewardMemoryScopeDraft, rewardMemoryWriteValue } from "./reward-memory-settings";
import {isMemoryCapability, memorySettingsTitle, MemorySettingsHeader} from "./memory-settings";
Expand Down Expand Up @@ -322,18 +323,20 @@ function CapabilityCatalog({ callbacks, catalog, goalId, loading, notification,
<article aria-label={memorySelected ? memorySettingsTitle(locale) : localizedSelected.display_name} className="personal-capability-detail" tabIndex={0}>
{memorySelected ? <MemorySettingsHeader capabilities={orderedCapabilities} locale={locale}
selectedId={selected.capability_id} onSelect={setSelectedCapabilityId}
onRefresh={() => void onApplied()} disabled={editorBusy} /> : <CapabilityDetailHeader capability={selected} locale={locale}
onRefresh={() => void onApplied()} disabled={editorBusy} /> : selected.capability_id === "content_ops"
? <header><div><h2>{localizedSelected.display_name}</h2><p>{localizedSelected.description}</p></div></header>
: <CapabilityDetailHeader capability={selected} locale={locale}
source={localizedSelected.effective_configuration?.source} />}
{memorySelected && orderedCapabilities.some(item => item.capability_id === "decision_context") ? <section
aria-labelledby="memory-purpose-decision_context" hidden={selected.capability_id !== "decision_context"}
id="memory-panel-decision_context" role="tabpanel" tabIndex={0}>
<DecisionContextWorkbench key={goalId} goalId={goalId} active={selected.capability_id === "decision_context"} />
</section> : null}
<section hidden={selected.capability_id === "decision_context"}
<section className={selected.capability_id === "content_ops" ? "content-production-panel" : undefined} hidden={selected.capability_id === "decision_context"}
id={memorySelected && selected.capability_id !== "decision_context" ? `memory-panel-${selected.capability_id}` : undefined}
aria-labelledby={memorySelected && selected.capability_id !== "decision_context" ? `memory-purpose-${selected.capability_id}` : undefined}
role={memorySelected && selected.capability_id !== "decision_context" ? "tabpanel" : undefined}>
{localizedSelected.capability_id === "content_ops" ? <ContentReferenceWorkbench key={goalId} /> : <CapabilityEditorStatus available={editorAvailable} t={t}
{localizedSelected.capability_id === "content_ops" ? <><ContentMediaWorkbench key={goalId} goalId={goalId} /><details><summary>{locale === "zh-CN" ? "来源风格素材" : "Source style references"}</summary><ContentReferenceWorkbench key={goalId} /></details></> : <CapabilityEditorStatus available={editorAvailable} t={t}
description={readOnlyReason} />}

{selected.capability_id === "progress_review" && selected.observation ? <section className="personal-capability-observation" aria-label={locale === "zh-CN" ? "审查证据读回" : "Review evidence readback"}>
Expand Down
Loading
Loading