From a752fb2af1734b7440045ff835e82b7bab127500 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:53:34 +0800 Subject: [PATCH 1/9] feat(content-ops): render private compositions through optional media provider Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capability-localization.ts | 4 +- .../content-media-workbench.css | 25 ++ .../content-media-workbench.tsx | 89 ++++++ .../goal-capability-settings.tsx | 9 +- .../personal-workspace/memory-settings.tsx | 4 +- docs/reference/protocols/README.md | 1 + .../protocols/content-ops-media-v0.md | 117 +++++++ .../protocols/content-ops-reference-v0.md | 2 +- examples/content-ops-media-brief.json | 17 + loopx/capabilities/content_ops/README.md | 19 +- loopx/capabilities/content_ops/cli.py | 5 + loopx/capabilities/content_ops/media.py | 268 ++++++++++++++++ loopx/capabilities/content_ops/media_cli.py | 38 +++ loopx/capabilities/content_ops/reference.py | 4 +- .../capabilities/content_media.ts | 157 +++++++++ .../control_plane/effect_runtime_handlers.ts | 3 + loopx/presentation/configuration_api.py | 4 + loopx/presentation/content_media_api.py | 88 +++++ packages/loopx-media/README.md | 90 ++++++ packages/loopx-media/extension.toml | 16 + packages/loopx-media/pyproject.toml | 16 + .../loopx-media/src/loopx_media/__init__.py | 1 + .../loopx-media/src/loopx_media/renderer.py | 301 ++++++++++++++++++ tests/control_plane_ts/content_media.test.ts | 71 +++++ tests/test_content_media.py | 218 +++++++++++++ 25 files changed, 1551 insertions(+), 16 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.tsx create mode 100644 docs/reference/protocols/content-ops-media-v0.md create mode 100644 examples/content-ops-media-brief.json create mode 100644 loopx/capabilities/content_ops/media.py create mode 100644 loopx/capabilities/content_ops/media_cli.py create mode 100644 loopx/control_plane/capabilities/content_media.ts create mode 100644 loopx/presentation/content_media_api.py create mode 100644 packages/loopx-media/README.md create mode 100644 packages/loopx-media/extension.toml create mode 100644 packages/loopx-media/pyproject.toml create mode 100644 packages/loopx-media/src/loopx_media/__init__.py create mode 100644 packages/loopx-media/src/loopx_media/renderer.py create mode 100644 tests/control_plane_ts/content_media.test.ts create mode 100644 tests/test_content_media.py diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 326d3714bf..df3dbe8a60 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -14,7 +14,7 @@ const capabilityCopy: Record> = { 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", @@ -83,7 +83,7 @@ const capabilityCopy: Record> = { "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: "运行环境", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.css b/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.css new file mode 100644 index 0000000000..1d085b85f0 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.css @@ -0,0 +1,25 @@ +.content-production-panel {padding:0 24px 24px;} +.content-media {padding:16px 0 24px; border-bottom:1px solid var(--border, #e5e7eb);} +.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:16px; font-weight:600;} +.content-media p {margin:6px 0 14px; color:var(--muted-foreground, #666); font-size:13px; line-height:1.6;} +.content-media button {min-height:44px; border:1px solid var(--border, #dce0e5); border-radius:7px; padding:8px 12px; background:var(--background, white); color:inherit; cursor:pointer; font:inherit; font-size:13px;} +.content-media button:disabled {opacity:.5; cursor:default;} +.content-media button:focus-visible, .content-media a:focus-visible {outline:2px solid var(--accent, #2768ea); outline-offset:3px;} +.content-media-notice {padding:10px 12px; background:var(--muted, #f4f5f6); border-left:2px solid #8a95a5;} +.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(--muted, #f2f4f7); border-color:var(--border, #dce0e5);} +.content-media small {display:block; color:var(--muted-foreground, #687080); font-size:11px; line-height:1.5;} +.content-media-result {min-width:0;} +.content-media-result video {display:block; width:100%; margin:14px 0; border-radius:8px; background:#f5f7fb;} +.content-media-timeline {list-style:none; padding:0; margin:14px 0; border-top:1px solid var(--border, #e5e7eb);} +.content-media-timeline li {padding:12px 0; border-bottom:1px solid var(--border, #e5e7eb); font-size:13px;} +.content-media-timeline small {margin-top:4px;} +.content-media-downloads {display:flex; gap:16px; flex-wrap:wrap; font-size:13px;} +.content-media details {font-size:12px; margin-top:18px;} +.content-media details summary {cursor:pointer; color:var(--muted-foreground, #687080);} +.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;}} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.tsx new file mode 100644 index 0000000000..8b6e1c7405 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.tsx @@ -0,0 +1,89 @@ +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; qa: {frames: number; full_decode: string; narration: {mode: string}}; 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(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 = { + 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"; + return
+

{copy("制作视频", "Produce a video")}

+

{copy("让 Agent 准备脚本、来源和时间线,在这里制作并查看作品。", "Have your Agent prepare the story, sources and timeline. Render and review the result here.")}

+
+ {error ?

{error}

: null} + {!library && !error ?

{copy("正在读取作品…", "Loading compositions…")}

: null} + {library && !providerReady ?

{copy("本机视频扩展未就绪。作品与来源保留,安装并启用 loopx-media 后即可制作。", "The local video extension is unavailable. Prepared work is retained; install and enable loopx-media to render.")}

: null} + {library && !selected ?

{copy("还没有准备好的作品。把想讲的内容交给 Agent,它会通过 Content-Ops 保存可制作的脚本。", "No prepared compositions yet. Tell your Agent what to explain; it can save a renderable brief through Content-Ops.")}

: null} + {selected ?
+ +

{selected.title}

{status[selected.status] ?? selected.status} · {selected.duration_seconds}s · {copy("修订", "Revision")} {selected.revision}

+ {selected.status === "rendering" ? + : }
+ {selected.status === "ready" && selected.result ? <> + +

{copy("完整解码通过", "Full decode passed")} · {selected.result.qa.frames} {copy("帧", "frames")} · {copy("渲染耗时", "Render time")} {selected.result.cost.render_wall_seconds}s

+ :
    {selected.shots.map((shot, i) =>
  1. {shot.title}{shot.seconds}s · {shot.boundary}
  2. )}
} + {["failed", "damaged"].includes(selected.status) ?

{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.")}

: null} +
{copy("来源与检查", "Sources and checks")}
    {selected.shots.map((shot, i) =>
  • {shot.title} — {shot.boundary}
    {shot.source_ref}
  • )}
+

{copy("本机私有作品。编码通过不代表创意质量、理解效果或发布许可;请观看并试听。", "Private local work. Encoding does not establish creative quality, audience understanding or publication approval. Watch and listen before sharing.")}

+
: null} +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx index 9faf3b3bfd..29e578440d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx @@ -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"; @@ -322,18 +323,20 @@ function CapabilityCatalog({ callbacks, catalog, goalId, loading, notification,
{memorySelected ? void onApplied()} disabled={editorBusy} /> : void onApplied()} disabled={editorBusy} /> : selected.capability_id === "content_ops" + ?

{localizedSelected.display_name}

{localizedSelected.description}

+ : } {memorySelected && orderedCapabilities.some(item => item.capability_id === "decision_context") ? : null} -