cozyclay 3.0.3 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +29 -62
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +2 -0
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +4 -3
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/app-DmR7jBkz.js +5144 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
- package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
- package/dist/assets/task-board-ErQvfoaz.js +14 -0
- package/dist/assets/workflow-zQM-EKW1.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/mcp/tool-handlers.mjs +2 -2
- package/package.json +5 -3
- package/src/App.jsx +51 -282
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-tasks.js +11 -5
- package/src/command-bus.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +3 -3
- package/src/commands/objects.js +1 -1
- package/src/commands/project.js +1 -1
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/compile.js +7 -1
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +3 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +10 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +9 -8
- package/src/motion3/schema/types.js +4 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +10 -3
- package/src/motion3/skills/info.js +13 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-look.js +9 -5
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/primitive-look.js +38 -0
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +24 -35
- package/src/render-passes.js +7 -1
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +5 -9
- package/src/scenes.js +4 -5
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +1 -1
- package/src/studio-elements.js +6 -2
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +79 -46
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +25 -36
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +10 -17
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-BibyE5ar.js +0 -6
- package/dist/assets/app-DhRJ-PBz.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
- package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
- package/dist/assets/landing-DYg87MDY.js +0 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-BSA22Ofr.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- package/tools/perf/ik-camera-drag.mjs +0 -138
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Task mode inside the chat (task-board.js; the headless TaskBoard.jsx feeds taskView). A task is one quiet line
|
|
2
|
+
// under the turn that dispatched it; a status line under the header counts them; a line opens the task's own view
|
|
3
|
+
// (its steps, cost, cancel / undo, and an input that talks to that task only), and the chat is one click back.
|
|
4
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { FiChevronDown, FiChevronLeft } from "react-icons/fi";
|
|
6
|
+
import { ko } from "../locale.js";
|
|
7
|
+
import { ACTIVE_STATUSES, TASK_STATUS_COPY, boardGroups, compileState, costLabel, dispatchedTaskIds, progressLine, scopeLabel, taskLine, taskReason, taskView } from "./task-board.js";
|
|
8
|
+
import "./task-board.css";
|
|
9
|
+
export { readStoredTaskMode, storeTaskMode } from "./task-board.js";
|
|
10
|
+
|
|
11
|
+
const useTaskView = () => useSyncExternalStore(taskView.subscribe, taskView.get, taskView.get);
|
|
12
|
+
const openTask = taskId => taskView.set({ open: taskId });
|
|
13
|
+
|
|
14
|
+
function TaskRow({ task, activity }) {
|
|
15
|
+
const line = task.status === "running" && compileState(activity, task.taskId).waiting > 0 ? ko("Compile waits for a free worker", "컴파일 차례 기다리는 중") : taskLine(task);
|
|
16
|
+
const status = TASK_STATUS_COPY[task.status] ?? task.status;
|
|
17
|
+
return <button type="button" className="task-row" data-status={task.status} data-task-id={task.taskId} onClick={() => openTask(task.taskId)}
|
|
18
|
+
title={`${task.title} — ${line}`} aria-label={`${task.title}, ${status}: ${line}`}>
|
|
19
|
+
<span className="task-dot" aria-hidden="true" />
|
|
20
|
+
<span className="task-row-title">{task.title}</span>
|
|
21
|
+
<span className="task-row-line">{line}</span>
|
|
22
|
+
</button>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The tasks a turn dispatched (`steps`), or with `items` every task no turn in the chat dispatched (a restored chat). */
|
|
26
|
+
export function TaskRows({ steps, items }) {
|
|
27
|
+
const { board, activity } = useTaskView();
|
|
28
|
+
const placed = items ? new Set(dispatchedTaskIds(items)) : null;
|
|
29
|
+
const ids = placed ? board.order.filter(id => !placed.has(id)) : [...new Set(dispatchedTaskIds(steps))].filter(id => board.tasks[id]);
|
|
30
|
+
if (!ids.length) return null;
|
|
31
|
+
return <div className="task-rows">{ids.map(id => <TaskRow key={id} task={board.tasks[id]} activity={activity} />)}</div>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const GROUPS = [["failed", ko("need a look", "확인 필요")], ["running", ko("running", "실행 중")], ["waiting", ko("waiting", "대기")], ["done", ko("done", "완료")]];
|
|
35
|
+
|
|
36
|
+
export function TaskStrip() {
|
|
37
|
+
const { board, activity } = useTaskView();
|
|
38
|
+
const [expanded, setExpanded] = useState(false);
|
|
39
|
+
if (!board.order.length) return null;
|
|
40
|
+
const groups = boardGroups(board);
|
|
41
|
+
const shown = GROUPS.filter(([key]) => groups[key].length);
|
|
42
|
+
return <div className="task-strip" data-expanded={expanded ? "true" : "false"}>
|
|
43
|
+
<button type="button" className="task-strip-head" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>
|
|
44
|
+
{shown.map(([key, label]) => <span key={key} className="task-strip-count" data-status={key}><span className="task-dot" aria-hidden="true" />{groups[key].length} {label}</span>)}
|
|
45
|
+
<FiChevronDown className="task-strip-caret" size={12} aria-hidden="true" />
|
|
46
|
+
</button>
|
|
47
|
+
{expanded && <div className="task-rows task-strip-list">{shown.flatMap(([key]) => groups[key]).map(task => <TaskRow key={task.taskId} task={task} activity={activity} />)}</div>}
|
|
48
|
+
</div>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function TaskFrames({ client, turnId, labelOf }) {
|
|
52
|
+
const [lines, setLines] = useState([]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!turnId || !client) return undefined;
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
let reply = "";
|
|
57
|
+
client.frames(turnId, frame => {
|
|
58
|
+
if (frame.type === "text.delta") { reply += frame.text ?? ""; setLines(list => [...list.filter(line => line.kind !== "reply"), { kind: "reply", text: reply }]); return; }
|
|
59
|
+
const label = frame.type === "tool.done" ? null : progressLine(frame, labelOf);
|
|
60
|
+
if (frame.type === "tool.start") reply = "";
|
|
61
|
+
if (label) setLines(list => [...list.filter(line => line.kind !== "reply"), ...(reply ? [{ kind: "said", text: reply }] : []), { kind: frame.type === "error" ? "error" : "tool", text: label }].slice(-40));
|
|
62
|
+
}, controller.signal).catch(() => {});
|
|
63
|
+
return () => controller.abort();
|
|
64
|
+
}, [client, turnId, labelOf]);
|
|
65
|
+
if (!lines.length) return <p className="task-frames-empty">{ko("No steps yet.", "아직 단계가 없어요.")}</p>;
|
|
66
|
+
return <ol className="task-frames">{lines.map((line, index) => <li key={index} data-kind={line.kind}>{line.text}</li>)}</ol>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function TaskDetail() {
|
|
70
|
+
const { board, open, client, undo, labelOf } = useTaskView();
|
|
71
|
+
const task = open ? board.tasks[open] : null;
|
|
72
|
+
return task ? <OpenTask key={task.taskId} task={task} client={client} undo={undo} labelOf={labelOf} /> : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function OpenTask({ task, client, undo, labelOf }) {
|
|
76
|
+
const [notice, setNotice] = useState("");
|
|
77
|
+
const [steering, setSteering] = useState("");
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const onKey = event => { if (event.key === "Escape") openTask(null); };
|
|
80
|
+
window.addEventListener("keydown", onKey);
|
|
81
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
82
|
+
}, []);
|
|
83
|
+
const active = ACTIVE_STATUSES.has(task.status);
|
|
84
|
+
const cost = costLabel(task.usage);
|
|
85
|
+
const act = async (run, done) => { setNotice(""); try { await run(); setNotice(done); } catch (error) { setNotice(String(error?.message ?? error)); } };
|
|
86
|
+
const steer = event => {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
const text = steering.trim();
|
|
89
|
+
if (text) act(() => client.steer(task.taskId, text), ko("Sent to this task.", "이 작업에 전달함.")).then(() => setSteering(""));
|
|
90
|
+
};
|
|
91
|
+
return <section className="task-detail" data-status={task.status} data-task-id={task.taskId} aria-label={task.title}>
|
|
92
|
+
<header className="task-detail-head">
|
|
93
|
+
<button type="button" className="agent-ghost-button task-detail-back" onClick={() => openTask(null)} autoFocus><FiChevronLeft size={13} aria-hidden="true" />{ko("Chat", "대화")}</button>
|
|
94
|
+
<span className="task-detail-title">{task.title}</span>
|
|
95
|
+
</header>
|
|
96
|
+
<div className="task-detail-body">
|
|
97
|
+
<div className="task-detail-meta" data-status={task.status}>
|
|
98
|
+
<span className="task-dot" aria-hidden="true" />
|
|
99
|
+
<span>{TASK_STATUS_COPY[task.status] ?? task.status}{cost ? ` · ${cost}` : ""}</span>
|
|
100
|
+
{active && <button type="button" className="agent-ghost-button task-detail-cancel" onClick={() => act(() => client.cancel(task.taskId), ko("Cancelled; its changes go back.", "취소함: 바뀐 것은 되돌아가요."))}>{ko("Cancel", "취소")}</button>}
|
|
101
|
+
{task.status === "done" && !task.undone && <button type="button" className="agent-ghost-button task-detail-undo" onClick={() => act(() => undo(task.taskId), ko("Undone.", "되돌림."))}>{ko("Undo task", "작업 되돌리기")}</button>}
|
|
102
|
+
</div>
|
|
103
|
+
{task.scope?.length > 0 && <p className="task-detail-scope" title={task.scope.join(", ")}>{ko("Touches", "건드리는 것")}: {[...new Set(task.scope.map(scopeLabel))].join(" · ")}</p>}
|
|
104
|
+
{task.reason && task.status !== "done" && <p className="task-detail-reason">{taskReason(task.reason)}</p>}
|
|
105
|
+
{task.notes?.map((text, index) => <p key={index} className="task-detail-note">{text}</p>)}
|
|
106
|
+
{task.summary?.reply && !active && <p className="task-detail-summary">{task.summary.reply}</p>}
|
|
107
|
+
<TaskFrames key={task.turnId} client={client} turnId={task.turnId} labelOf={labelOf} />
|
|
108
|
+
{notice && <p className="task-detail-notice" role="status">{notice}</p>}
|
|
109
|
+
</div>
|
|
110
|
+
{task.status === "running" && <form className="task-detail-steer" onSubmit={steer}>
|
|
111
|
+
<input value={steering} onChange={event => setSteering(event.target.value)} placeholder={ko("Tell this task…", "이 작업에게 지시…")} aria-label={ko("Tell this task", "이 작업에게 지시")} />
|
|
112
|
+
</form>}
|
|
113
|
+
</section>;
|
|
114
|
+
}
|
|
@@ -28,8 +28,9 @@ import { appendAttachments, ATTACHMENT_MAX_COUNT } from "./attachment-image.js";
|
|
|
28
28
|
import { ko } from "../locale.js";
|
|
29
29
|
import { AGENT_TOOL_CATEGORIES, EXECUTION_TELEMETRY_VALUES } from "../execution-telemetry.js";
|
|
30
30
|
import { STUDIO_VARIANTS, validateReceipt } from "../studio-agent-protocol.js";
|
|
31
|
-
import { requestAgentImage, sidecarRequest, sidecarUrl } from "../agent-image-client.js";
|
|
31
|
+
import { requestAgentImage, sidecarRequest, sidecarUrl } from "../agent-image-client.js"; import { clearSculptSketch, sculptSketchFrame } from "../sculpt-sketch-state.js";
|
|
32
32
|
import { actionLabelsOf, changedLine, scriptNow, stepFields, thinkingLine, withCompose, withThinking, withToolStart, withTurnEnd } from "./agent-turn.js";
|
|
33
|
+
import { isPlanFrame, restoredPlans, withPlanEnd, withPlanFrame } from "./agent-plan.js";
|
|
33
34
|
|
|
34
35
|
const AGENT_PANEL_WIDTH_KEY = "cozyclay.workflow.agentPanel.width";
|
|
35
36
|
export const AGENT_PANEL_WIDTH_DEFAULT = 360;
|
|
@@ -266,27 +267,6 @@ export function storeModel(key, storage = globalThis.localStorage) {
|
|
|
266
267
|
}
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
/** The supervisor switch (#728): off unless the author turned it on. It rides
|
|
270
|
-
* on every Studio turn as `supervisor: true|false`; the sidecar reviews only a
|
|
271
|
-
* turn sent with `true`, and its own COZYCLAY_SUPERVISOR=off still wins. */
|
|
272
|
-
const AGENT_SUPERVISOR_KEY = "cozyclay.agent.supervisor";
|
|
273
|
-
|
|
274
|
-
export function readStoredSupervisor(storage = globalThis.localStorage) {
|
|
275
|
-
try {
|
|
276
|
-
return storage?.getItem(AGENT_SUPERVISOR_KEY) === "on";
|
|
277
|
-
} catch {
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export function storeSupervisor(enabled, storage = globalThis.localStorage) {
|
|
283
|
-
try {
|
|
284
|
-
storage?.setItem(AGENT_SUPERVISOR_KEY, enabled ? "on" : "off");
|
|
285
|
-
} catch {
|
|
286
|
-
// Private-mode storage denial must never break the toggle.
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
270
|
/** The model to open with: the remembered key when it is still advertised,
|
|
291
271
|
* otherwise the first model the sidecar listed. */
|
|
292
272
|
export function preferredModel(models, stored = readStoredModel()) {
|
|
@@ -653,6 +633,10 @@ export function keyRefusedMessage(raw) {
|
|
|
653
633
|
return `The model provider refused the credentials${status || reason ? ` (${[status, reason].filter(Boolean).join(": ")})` : ""}.`;
|
|
654
634
|
}
|
|
655
635
|
|
|
636
|
+
/** True when the agent server itself did not answer: plain `vite` without the sidecar (503/502/404), a dead
|
|
637
|
+
* port (network error, no status), or an HTML fallback where JSON was expected. Any other status is the server's own answer. */
|
|
638
|
+
export const agentServerUnavailable = (error) => !Number.isFinite(error?.status) || [404, 502, 503].includes(error.status);
|
|
639
|
+
|
|
656
640
|
export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalThis), surface, capture = track, now = () => performance.now() } = {}) {
|
|
657
641
|
const activeTurns = new Map();
|
|
658
642
|
const request = (path, init) => sidecarRequest(fetchImpl, path, init);
|
|
@@ -744,7 +728,7 @@ export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalT
|
|
|
744
728
|
},
|
|
745
729
|
/** Streams sidecar events to `onEvent`. Resolves when the turn ends. */
|
|
746
730
|
async turn(turnRequest, onEvent, signal) {
|
|
747
|
-
const { sessionId, text, attachFrame, model, effort, attachments, supervisor } = turnRequest;
|
|
731
|
+
const { sessionId, text, attachFrame, model, effort, attachments, supervisor, tasks } = turnRequest;
|
|
748
732
|
const attached = Array.isArray(attachments) && attachments.length ? { attachments } : {};
|
|
749
733
|
const studio = turnRequest.surface === "studio";
|
|
750
734
|
const turnId = studio ? turnRequest.turnId : null;
|
|
@@ -803,9 +787,10 @@ export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalT
|
|
|
803
787
|
})
|
|
804
788
|
: JSON.stringify({ sessionId, text, attachFrame, model, ...(effort ? { effort } : {}), ...attached, ...(telemetry.turnId ? { turn_id: telemetry.turnId } : {}) });
|
|
805
789
|
try {
|
|
806
|
-
|
|
790
|
+
// Task mode (the panel's Tasks switch): the turn goes to the main agent, which answers at once and runs tasks.
|
|
791
|
+
const response = await fetchImpl(sidecarUrl(studio && tasks ? "/agent/main" : "/agent/turn"), {
|
|
807
792
|
method: "POST",
|
|
808
|
-
headers: { "content-type": "application/json", accept: "text/event-stream" },
|
|
793
|
+
headers: { "content-type": "application/json", accept: "text/event-stream", ...(studio && tasks ? { "x-cozyclay-tasks": "on" } : {}) },
|
|
809
794
|
body,
|
|
810
795
|
signal,
|
|
811
796
|
});
|
|
@@ -1341,6 +1326,8 @@ export function createAgentChatStore({
|
|
|
1341
1326
|
});
|
|
1342
1327
|
return;
|
|
1343
1328
|
}
|
|
1329
|
+
// The turn's step checklist (bin/agent/plan-tool.mjs): one card under the prompt; the plan call itself is no step.
|
|
1330
|
+
if (isPlanFrame(event)) { setItems((items) => withPlanFrame(items, event, newId)); return; }
|
|
1344
1331
|
if (event?.type === "tool.start") {
|
|
1345
1332
|
setItems((items) => withToolStart(items, { kind: "tool", id: event.callId || newId(), callId: event.callId, name: event.name, label: event.label, args: event.args, status: "running", startedAt: clock(), ...stepFields(event.name, event.args, actionLabels) }));
|
|
1346
1333
|
return;
|
|
@@ -1422,11 +1409,10 @@ export function createAgentChatStore({
|
|
|
1422
1409
|
set({ sessionId: newId(), turnId: null, items: [], pendingAttachments: [], rateLimit: null, lastPrompt: "", turnStartedAt: null, lastTurn: null });
|
|
1423
1410
|
};
|
|
1424
1411
|
const restore = (transcript, sessionId = state.sessionId) => {
|
|
1425
|
-
abandonSupervisorNotes();
|
|
1426
|
-
seenReceipts.clear();
|
|
1427
|
-
settledActions.clear();
|
|
1412
|
+
abandonSupervisorNotes(); seenReceipts.clear(); settledActions.clear();
|
|
1428
1413
|
const restoredReceiptIds = new Set();
|
|
1429
|
-
|
|
1414
|
+
// Each turn's plan card comes back under its prompt, settled by how the turn ended (agent-plan.js).
|
|
1415
|
+
const restored = restoredPlans((Array.isArray(transcript) ? transcript : []).flatMap((item) => {
|
|
1430
1416
|
if (item?.kind === "user" || item?.kind === "assistant") {
|
|
1431
1417
|
const attachments = Array.isArray(item.attachments) ? item.attachments.filter((entry) => typeof entry?.dataUrl === "string") : [];
|
|
1432
1418
|
return [{ kind: item.kind, id: newId(), text: String(item.text ?? ""), ...(attachments.length ? { attachments } : {}) }];
|
|
@@ -1441,8 +1427,8 @@ export function createAgentChatStore({
|
|
|
1441
1427
|
return [{ kind: "receipt", id: `receipt:${item.receiptId || newId()}`, receiptId: item.receiptId, summary: item.summary || "Receipt", receipt: { status: "applied", warnings: [], verification: { status: "verified" } } }];
|
|
1442
1428
|
}
|
|
1443
1429
|
if (item?.kind === "failure") return [{ kind: "failure", id: newId(), failure: { code: item.code || "upstream", message: String(item.message || "The turn failed."), recovery: { action: "retry", retryAllowed: true } } }];
|
|
1444
|
-
return [];
|
|
1445
|
-
});
|
|
1430
|
+
return item?.kind === "plan" ? [{ kind: "plan", id: newId(), steps: item.steps }] : [];
|
|
1431
|
+
}));
|
|
1446
1432
|
const lastPrompt = [...restored].reverse().find((item) => item.kind === "user")?.text || "";
|
|
1447
1433
|
set({ sessionId, turnId: null, items: restored, rateLimit: null, lastPrompt, turnStartedAt: null, lastTurn: null, streaming: false });
|
|
1448
1434
|
return restored;
|
|
@@ -1454,6 +1440,8 @@ export function createAgentChatStore({
|
|
|
1454
1440
|
return () => listeners.delete(listener);
|
|
1455
1441
|
},
|
|
1456
1442
|
setDraft: (draft) => set({ draft: String(draft ?? "") }),
|
|
1443
|
+
/** A reply the main agent made on its own (task mode: a task ended while nobody was talking to it). */
|
|
1444
|
+
note: (text) => { if (String(text ?? "").trim()) set({ items: [...state.items, { kind: "assistant", id: newId(), text: String(text) }] }); },
|
|
1457
1445
|
/** Pictures pasted or dropped into the composer. Returns what was taken and
|
|
1458
1446
|
* what had to be refused, so the composer can say so instead of a picture
|
|
1459
1447
|
* vanishing on its way in. */
|
|
@@ -1511,15 +1499,16 @@ export function createAgentChatStore({
|
|
|
1511
1499
|
attachFrame: Boolean(options.attachFrame),
|
|
1512
1500
|
model: options.model,
|
|
1513
1501
|
effort: options.effort ?? undefined,
|
|
1514
|
-
...(typeof options.supervisor === "boolean" ? { supervisor: options.supervisor } : {}),
|
|
1515
|
-
}, applyEvent, signal);
|
|
1502
|
+
...(typeof options.supervisor === "boolean" ? { supervisor: options.supervisor } : {}), ...(options.tasks ? { tasks: true } : {}),
|
|
1503
|
+
}, (event) => { sculptSketchFrame(event); applyEvent(event); }, signal);
|
|
1516
1504
|
} catch (error) {
|
|
1517
1505
|
if (!signal.aborted) applyEvent({ type: "error", code: "upstream", message: String(error?.message || error) });
|
|
1518
1506
|
} finally {
|
|
1519
1507
|
if (controller?.signal === signal) controller = null;
|
|
1520
|
-
// A turn the author already replaced owns none of this state.
|
|
1508
|
+
// A turn the author already replaced owns none of this state. Its live sculpt sketch
|
|
1509
|
+
// (the ghost clay of a call still being written) ends with it, whatever ended it.
|
|
1521
1510
|
if (activeTurn === turn) {
|
|
1522
|
-
wireTurnId = null;
|
|
1511
|
+
wireTurnId = null; clearSculptSketch();
|
|
1523
1512
|
// Ending with nothing on screen is a failure, not a result.
|
|
1524
1513
|
if (!turn.produced && !turn.failure && !signal.aborted) {
|
|
1525
1514
|
turn.failure = { code: "no_output", message: "The turn ended without a response.", recovery: { action: "retry", retryAllowed: true } };
|
|
@@ -1529,7 +1518,7 @@ export function createAgentChatStore({
|
|
|
1529
1518
|
activeTurn = null;
|
|
1530
1519
|
// The end state stays in the transcript with the turn's steps (agent-turn.js withTurnEnd).
|
|
1531
1520
|
const lastTurn = { status: signal.aborted ? "stopped" : turn.failure ? "failed" : "done", endedAt, durationMs: endedAt - startedAt, failure: turn.failure };
|
|
1532
|
-
set({ streaming: false, turnStartedAt: null, lastTurn, items: withTurnEnd(state.items, lastTurn, newId) });
|
|
1521
|
+
set({ streaming: false, turnStartedAt: null, lastTurn, items: withPlanEnd(withTurnEnd(state.items, lastTurn, newId), lastTurn.status) });
|
|
1533
1522
|
if (studio && turn.reviewable && options.supervisor === true && !signal.aborted) requestSupervisorNote(turnId);
|
|
1534
1523
|
}
|
|
1535
1524
|
}
|
|
@@ -287,7 +287,8 @@
|
|
|
287
287
|
cursor: pointer;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
.agent-menu button
|
|
290
|
+
.agent-menu button[disabled] { opacity: 0.45; cursor: not-allowed; }
|
|
291
|
+
.agent-menu button:not([disabled]):hover { background: var(--agent-hover, #2b3040); color: var(--agent-text-strong, #f5f7ff); }
|
|
291
292
|
|
|
292
293
|
/* --- account strip ----------------------------------------------------- */
|
|
293
294
|
|
|
@@ -869,6 +870,18 @@
|
|
|
869
870
|
/* The second way in on the signed-out card: a key or a server of one's own. */
|
|
870
871
|
.agent-state-card .agent-use-key { align-self: center; }
|
|
871
872
|
|
|
873
|
+
/* No agent server behind the page: a quiet status (icon + words), not an alert. */
|
|
874
|
+
.agent-offline-icon { align-self: center; color: var(--agent-text-muted); }
|
|
875
|
+
.agent-offline-card code {
|
|
876
|
+
padding: 0 var(--agent-space-1);
|
|
877
|
+
border-radius: var(--agent-radius-sm);
|
|
878
|
+
background: var(--agent-bg-sunken);
|
|
879
|
+
color: var(--agent-text);
|
|
880
|
+
font-size: var(--agent-font-sm);
|
|
881
|
+
}
|
|
882
|
+
.agent-offline-retry { align-self: center; display: inline-flex; align-items: center; gap: var(--agent-space-2); }
|
|
883
|
+
.agent-signin-error { align-self: center; max-width: 32ch; }
|
|
884
|
+
|
|
872
885
|
.agent-primary-button {
|
|
873
886
|
align-self: center;
|
|
874
887
|
border: 1px solid var(--agent-accent-edge);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* A turn's step checklist (AgentPlanCard.jsx); colours, sizes and motion come from agent-panel.css. */
|
|
2
|
+
.agent-plan-row[data-plan-live="true"] {
|
|
3
|
+
position: sticky;
|
|
4
|
+
top: calc(-1 * var(--agent-space-5));
|
|
5
|
+
z-index: 1;
|
|
6
|
+
/* the transcript's own colour above the card, so steps scrolling under it never show through the gap */
|
|
7
|
+
padding-top: var(--agent-space-2);
|
|
8
|
+
margin-top: calc(-1 * var(--agent-space-2));
|
|
9
|
+
background: var(--agent-bg);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.agent-plan {
|
|
13
|
+
--agent-plan-mark: 11px;
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.agent-plan-row[data-plan-live="true"] .agent-plan { box-shadow: 0 6px 14px -8px #000c; }
|
|
19
|
+
|
|
20
|
+
.agent-plan-head {
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
gap: var(--agent-space-3);
|
|
24
|
+
padding: var(--agent-space-2) var(--agent-space-4);
|
|
25
|
+
color: var(--agent-text-soft, var(--agent-text));
|
|
26
|
+
font-size: var(--agent-font-md);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.agent-plan-title { flex: none; font-weight: 600; }
|
|
30
|
+
|
|
31
|
+
.agent-plan-bar {
|
|
32
|
+
flex: 1;
|
|
33
|
+
height: 3px;
|
|
34
|
+
overflow: hidden;
|
|
35
|
+
border-radius: var(--agent-radius-pill);
|
|
36
|
+
background: var(--agent-track);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.agent-plan-fill {
|
|
40
|
+
display: block;
|
|
41
|
+
height: 100%;
|
|
42
|
+
background: var(--agent-ok);
|
|
43
|
+
transform: scaleX(var(--agent-plan-done, 0));
|
|
44
|
+
transform-origin: left;
|
|
45
|
+
transition: transform var(--agent-motion);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.agent-plan-count { flex: none; color: var(--agent-text-dim); font: var(--agent-font-sm) ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
|
49
|
+
|
|
50
|
+
.agent-plan-steps {
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
gap: 2px;
|
|
54
|
+
margin: 0;
|
|
55
|
+
padding: var(--agent-space-1) 0 var(--agent-space-2);
|
|
56
|
+
border-top: 1px solid var(--agent-border);
|
|
57
|
+
list-style: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.agent-plan-step {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: var(--agent-space-3);
|
|
64
|
+
min-width: 0;
|
|
65
|
+
padding: 2px var(--agent-space-4);
|
|
66
|
+
color: var(--agent-text-muted);
|
|
67
|
+
font-size: var(--agent-font-md);
|
|
68
|
+
line-height: 1.4;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.agent-plan-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
72
|
+
.agent-plan-step.done { color: var(--agent-text-dim); }
|
|
73
|
+
.agent-plan-step.active { background: color-mix(in srgb, var(--agent-accent) 12%, transparent); color: var(--agent-text); }
|
|
74
|
+
.agent-plan-step.unfinished { color: var(--agent-text-dim); }
|
|
75
|
+
.agent-plan-step.unfinished .agent-plan-text { font-style: italic; }
|
|
76
|
+
|
|
77
|
+
.agent-plan-mark { flex: none; width: var(--agent-plan-mark); height: var(--agent-plan-mark); }
|
|
78
|
+
.agent-plan-mark.done { color: var(--agent-ok); }
|
|
79
|
+
.agent-plan-mark.unfinished { color: var(--agent-warn); }
|
|
80
|
+
.agent-plan-mark.pending { border: 1.5px solid var(--agent-border-strong); border-radius: var(--agent-radius-pill); }
|
|
81
|
+
/* the step under way: an accent ring whose centre breathes while the turn runs */
|
|
82
|
+
.agent-plan-mark.active { position: relative; border: 1.5px solid var(--agent-accent); border-radius: var(--agent-radius-pill); }
|
|
83
|
+
.agent-plan-mark.active::after { content: ""; position: absolute; inset: 2px; border-radius: var(--agent-radius-pill); background: var(--agent-accent); }
|
|
84
|
+
.agent-plan[data-plan-state="live"] .agent-plan-mark.active::after { animation: agent-pulse 1.4s ease-in-out infinite; }
|
|
85
|
+
|
|
86
|
+
/* A finished turn's card is a record: it keeps its rows but no longer pulls the eye. */
|
|
87
|
+
.agent-plan:not([data-plan-state="live"]) .agent-plan-fill { background: var(--agent-text-dim); }
|
|
88
|
+
.agent-plan[data-plan-state="done"] .agent-plan-fill { background: var(--agent-ok); }
|
|
89
|
+
|
|
90
|
+
@media (prefers-reduced-motion: reduce) {
|
|
91
|
+
.agent-plan[data-plan-state="live"] .agent-plan-mark.active::after { animation: none; }
|
|
92
|
+
.agent-plan-fill { transition: none; }
|
|
93
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A turn's step checklist as the agent panel keeps it (AgentPlanCard.jsx draws it): the sidecar's plan tool
|
|
2
|
+
// (bin/agent/plan-tool.mjs) sends the whole list in every plan.update frame, and the chat store (agent-client.js) keeps
|
|
3
|
+
// one plan card per turn, right under the turn's prompt. The card never claims more than happened: when the turn ends,
|
|
4
|
+
// the step still under way counts as done only if the turn succeeded, and is left unfinished when it failed or stopped.
|
|
5
|
+
// Pure data, like agent-turn.js.
|
|
6
|
+
|
|
7
|
+
const STATUSES = new Set(["pending", "active", "done"]);
|
|
8
|
+
const MAX_STEPS = 12, TEXT_MAX = 80;
|
|
9
|
+
|
|
10
|
+
/** A frame's steps as the card keeps them, or null when they are not a plan. */
|
|
11
|
+
function planSteps(steps) {
|
|
12
|
+
if (!Array.isArray(steps) || !steps.length || steps.length > MAX_STEPS) return null;
|
|
13
|
+
const kept = steps.map(step => ({ text: String(step?.text ?? "").replace(/\s+/g, " ").trim().slice(0, TEXT_MAX), status: step?.status }));
|
|
14
|
+
return kept.every(step => step.text && STATUSES.has(step.status)) ? kept : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether a frame belongs to the plan: its update, or the plan tool's own call (the card shows it, never a step row). */
|
|
18
|
+
export const isPlanFrame = event => event?.type === "plan.update" || (event?.name === "plan" && ["tool.compose", "tool.start", "tool.progress"].includes(event?.type));
|
|
19
|
+
|
|
20
|
+
/** items after a plan frame: the turn's one plan card, under its prompt, takes the frame's list whole. */
|
|
21
|
+
export function withPlanFrame(items, event, newId) {
|
|
22
|
+
const steps = event?.type === "plan.update" ? planSteps(event.steps) : null;
|
|
23
|
+
if (!steps) return items;
|
|
24
|
+
const prompt = items.findLastIndex(item => item.kind === "user" && !item.steered);
|
|
25
|
+
const at = items.findIndex((item, i) => i > prompt && item.kind === "plan");
|
|
26
|
+
if (at !== -1) return items.map((item, i) => i === at ? { ...item, steps } : item);
|
|
27
|
+
return [...items.slice(0, prompt + 1), { kind: "plan", id: newId(), steps }, ...items.slice(prompt + 1)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A plan whose turn ended ("done", "failed" or "stopped"): the step under way is done only when the turn succeeded. */
|
|
31
|
+
export function settledPlan(plan, status) {
|
|
32
|
+
const outcome = status === "done" || status === "stopped" ? status : "failed";
|
|
33
|
+
return { ...plan, outcome, steps: plan.steps.map(step => step.status === "active" ? { ...step, status: outcome === "done" ? "done" : "unfinished" } : step) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** items at the end of a turn: its plan card settles (settledPlan); earlier turns' cards already have. */
|
|
37
|
+
export const withPlanEnd = (items, status) => items.map(item => item.kind === "plan" && !item.outcome ? settledPlan(item, status) : item);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A restored transcript's plans (session-store.mjs lists one per change): each turn keeps its last list, under its
|
|
41
|
+
* prompt; the plan tool's own calls go; and the card settles by how the turn ended: failed when a failure follows, done
|
|
42
|
+
* when the turn ends on the reply, else stopped (the history ends mid-turn: the panel reloaded while it ran).
|
|
43
|
+
*/
|
|
44
|
+
export function restoredPlans(items) {
|
|
45
|
+
const turns = [];
|
|
46
|
+
for (const item of items) {
|
|
47
|
+
if (item.kind === "tool" && item.name === "plan") continue;
|
|
48
|
+
if (item.kind === "user" || !turns.length) turns.push([]);
|
|
49
|
+
turns.at(-1).push(item);
|
|
50
|
+
}
|
|
51
|
+
return turns.flatMap(turn => {
|
|
52
|
+
const rest = turn.filter(item => item.kind !== "plan"), plan = turn.findLast(item => item.kind === "plan");
|
|
53
|
+
const steps = plan ? planSteps(plan.steps) : null;
|
|
54
|
+
if (!steps) return rest;
|
|
55
|
+
const status = rest.some(item => item.kind === "failure") ? "failed" : rest.at(-1)?.kind === "assistant" ? "done" : "stopped";
|
|
56
|
+
const prompt = rest[0]?.kind === "user" ? 1 : 0;
|
|
57
|
+
return [...rest.slice(0, prompt), settledPlan({ ...plan, steps }, status), ...rest.slice(prompt)];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Anything under it is sent verbatim: no re-encode, no generation loss.
|
|
11
11
|
*/
|
|
12
12
|
import { fileToDataUrl, imageFileFromTransfer } from "./clipboard-image.js";
|
|
13
|
+
import { isVideoFile, videoContactSheet } from "./attachment-video.js";
|
|
13
14
|
|
|
14
15
|
export const ATTACHMENT_MAX_COUNT = 4;
|
|
15
16
|
export const ATTACHMENT_MAX_DIMENSION = 1600;
|
|
@@ -19,8 +20,9 @@ export const ATTACHMENT_JPEG_QUALITY = 0.85;
|
|
|
19
20
|
* rest here rather than letting the sidecar reject a turn the author sent. */
|
|
20
21
|
const ATTACHMENT_TYPES = Object.freeze(["image/png", "image/jpeg", "image/webp"]);
|
|
21
22
|
export const ATTACHMENT_LIMIT_NOTICE = "Up to 4 images per message";
|
|
22
|
-
export const ATTACHMENT_TYPE_NOTICE = "Attach a PNG, JPEG or WebP image";
|
|
23
|
-
export const ATTACHMENT_FAILED_NOTICE = "That image could not be attached";
|
|
23
|
+
export const ATTACHMENT_TYPE_NOTICE = "Attach a PNG, JPEG or WebP image, or a video";
|
|
24
|
+
export const ATTACHMENT_FAILED_NOTICE = "That image or video could not be attached";
|
|
25
|
+
export const ATTACHMENT_ACCEPT = [...ATTACHMENT_TYPES, "video/*"].join(",");
|
|
24
26
|
|
|
25
27
|
const EXTENSION_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
|
|
26
28
|
|
|
@@ -57,7 +59,8 @@ export function attachmentFilesFromTransfer(transfer) {
|
|
|
57
59
|
const images = [];
|
|
58
60
|
let unsupported = 0;
|
|
59
61
|
for (const file of candidates) {
|
|
60
|
-
|
|
62
|
+
// A video travels as a contact sheet image (attachment-video.js).
|
|
63
|
+
if (attachmentType(file) || isVideoFile(file)) images.push(file);
|
|
61
64
|
else if (String(file?.type ?? "").startsWith("image/")) unsupported += 1;
|
|
62
65
|
}
|
|
63
66
|
return { images, unsupported };
|
|
@@ -155,6 +158,10 @@ export function attachmentName(file) {
|
|
|
155
158
|
* media type is the resolved one: a file dragged in without a type would
|
|
156
159
|
* otherwise be labelled PNG whatever its bytes are. */
|
|
157
160
|
export async function attachmentFromFile(file, options = {}) {
|
|
161
|
+
if (isVideoFile(file)) {
|
|
162
|
+
const sheet = await videoContactSheet(file);
|
|
163
|
+
return { dataUrl: await blobToDataUrl(sheet.blob, "image/jpeg"), name: sheet.name };
|
|
164
|
+
}
|
|
158
165
|
const { readDataUrl = fileToDataUrl, ...shrink } = options;
|
|
159
166
|
const type = attachmentType(file) ?? "image/png";
|
|
160
167
|
const read = await readDataUrl(file);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A video the author attaches to a turn. The turn envelope carries images
|
|
3
|
+
* only, and the models behind it read pictures, not clips, so the composer
|
|
4
|
+
* turns a video into one contact sheet: evenly spaced frames in a grid, each
|
|
5
|
+
* stamped with its time. It costs one attachment slot and keeps the order of
|
|
6
|
+
* the action legible to the model.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "ogv", "mkv"]);
|
|
10
|
+
const SHEET_FRAMES = 9;
|
|
11
|
+
const SHEET_COLUMNS = 3;
|
|
12
|
+
const SHEET_CELL_WIDTH = 480;
|
|
13
|
+
const SHEET_JPEG_QUALITY = 0.85;
|
|
14
|
+
|
|
15
|
+
export function isVideoFile(file) {
|
|
16
|
+
const declared = String(file?.type ?? "").toLowerCase();
|
|
17
|
+
if (declared) return declared.startsWith("video/");
|
|
18
|
+
const extension = String(file?.name ?? "").toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
|
19
|
+
return VIDEO_EXTENSIONS.has(extension);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sheetTimes(duration, count) {
|
|
23
|
+
return Array.from({ length: count }, (_, index) => (index + 0.5) * duration / count);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatSheetTime(seconds) {
|
|
27
|
+
const minutes = Math.floor(seconds / 60);
|
|
28
|
+
return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nextEvent(target, name) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const done = () => { target.removeEventListener(name, done); target.removeEventListener("error", failed); resolve(); };
|
|
34
|
+
const failed = () => { target.removeEventListener(name, done); target.removeEventListener("error", failed); reject(new Error("this browser cannot decode that video")); };
|
|
35
|
+
target.addEventListener(name, done);
|
|
36
|
+
target.addEventListener("error", failed);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One video file as a JPEG contact sheet `{ blob, name }`. Decoding happens in
|
|
42
|
+
* a detached <video>; a codec the browser cannot play rejects instead of
|
|
43
|
+
* producing a black sheet.
|
|
44
|
+
*/
|
|
45
|
+
export async function videoContactSheet(file, { doc = globalThis.document, frames = SHEET_FRAMES } = {}) {
|
|
46
|
+
const url = URL.createObjectURL(file);
|
|
47
|
+
const video = doc.createElement("video");
|
|
48
|
+
video.muted = true;
|
|
49
|
+
video.playsInline = true;
|
|
50
|
+
video.preload = "auto";
|
|
51
|
+
try {
|
|
52
|
+
const loaded = nextEvent(video, "loadeddata");
|
|
53
|
+
video.src = url;
|
|
54
|
+
await loaded;
|
|
55
|
+
const { duration, videoWidth, videoHeight } = video;
|
|
56
|
+
if (!Number.isFinite(duration) || duration <= 0 || !videoWidth || !videoHeight) throw new Error("that video has no usable length or size");
|
|
57
|
+
const cellWidth = Math.min(SHEET_CELL_WIDTH, videoWidth);
|
|
58
|
+
const cellHeight = Math.round(cellWidth * videoHeight / videoWidth);
|
|
59
|
+
const canvas = doc.createElement("canvas");
|
|
60
|
+
canvas.width = cellWidth * SHEET_COLUMNS;
|
|
61
|
+
canvas.height = cellHeight * Math.ceil(frames / SHEET_COLUMNS);
|
|
62
|
+
const context = canvas.getContext("2d");
|
|
63
|
+
if (!context) throw new Error("this browser cannot draw video frames — no 2D context");
|
|
64
|
+
context.fillStyle = "#000";
|
|
65
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
66
|
+
context.font = `600 ${Math.max(12, Math.round(cellHeight / 12))}px system-ui, sans-serif`;
|
|
67
|
+
context.textBaseline = "top";
|
|
68
|
+
for (const [index, time] of sheetTimes(duration, frames).entries()) {
|
|
69
|
+
const seeked = nextEvent(video, "seeked");
|
|
70
|
+
video.currentTime = time;
|
|
71
|
+
await seeked;
|
|
72
|
+
const x = (index % SHEET_COLUMNS) * cellWidth;
|
|
73
|
+
const y = Math.floor(index / SHEET_COLUMNS) * cellHeight;
|
|
74
|
+
context.drawImage(video, x, y, cellWidth, cellHeight);
|
|
75
|
+
const stamp = `${index + 1} · ${formatSheetTime(time)}`;
|
|
76
|
+
const pad = 6;
|
|
77
|
+
const box = context.measureText(stamp).width + pad * 2;
|
|
78
|
+
context.fillStyle = "rgba(0, 0, 0, 0.6)";
|
|
79
|
+
context.fillRect(x + 4, y + 4, box, Math.round(cellHeight / 12) + pad * 2);
|
|
80
|
+
context.fillStyle = "#fff";
|
|
81
|
+
context.fillText(stamp, x + 4 + pad, y + 4 + pad);
|
|
82
|
+
}
|
|
83
|
+
const blob = await new Promise((resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error("the contact sheet could not be encoded")), "image/jpeg", SHEET_JPEG_QUALITY));
|
|
84
|
+
const base = String(file?.name ?? "").trim() || "video";
|
|
85
|
+
return { blob, name: `${base.slice(0, 70)} (${frames} frames over ${duration.toFixed(1)}s)` };
|
|
86
|
+
} finally {
|
|
87
|
+
video.removeAttribute("src");
|
|
88
|
+
video.load();
|
|
89
|
+
URL.revokeObjectURL(url);
|
|
90
|
+
}
|
|
91
|
+
}
|