@gtanczyk/diorama 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +289 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +142 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +142 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +36 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +192 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +90 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +91 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +31 -0
- package/app/src/scene/cast.ts +192 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +55 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +417 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +554 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +421 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +677 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1328 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3751 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Activity: what ran in this world, newest first. Each job is one row (what, who, how it went, how long, which
|
|
2
|
+
// models it called) with its steps folded under it; the agent's and your clicks fold into one line per run of them.
|
|
3
|
+
import { SAYS } from './agent.tsx';
|
|
4
|
+
import { MODEL_NAME, nowBy } from './build.tsx';
|
|
5
|
+
import { Icon, type IconName } from './icons.tsx';
|
|
6
|
+
|
|
7
|
+
export type ActivityJob = { id: string; label: string; args: string[]; state: string; by: string; error?: string; queuedAt?: string; startedAt?: string; endedAt?: string };
|
|
8
|
+
export type ActivityLine = { seq: number; text: string; kind: string; at?: number; job?: string; info?: ActivityJob; who?: 'you' | 'agent'; act?: string };
|
|
9
|
+
|
|
10
|
+
type Group =
|
|
11
|
+
| { type: 'job'; id: string; info: ActivityJob; lines: ActivityLine[]; at: number }
|
|
12
|
+
| { type: 'acts'; who: 'you' | 'agent'; acts: string[]; at: number; last: number }
|
|
13
|
+
| { type: 'line'; line: ActivityLine; at: number };
|
|
14
|
+
|
|
15
|
+
/** What a job is, as a sign. */
|
|
16
|
+
function sign(j: ActivityJob): IconName {
|
|
17
|
+
// A build of one step ("derive · canal-walk") is that step.
|
|
18
|
+
const [first] = j.label.split(/[\s·]/);
|
|
19
|
+
const cmd = j.args[0] === 'build' && STEPS.has(first) ? first : j.args[0];
|
|
20
|
+
if (cmd === 'build') return 'build';
|
|
21
|
+
if (j.args.includes('--animate') || /animate/i.test(j.label)) return 'cube';
|
|
22
|
+
if (cmd === 'cast' || cmd === 'model' || cmd === 'sprites') return 'person';
|
|
23
|
+
if (cmd === 'story' || cmd === 'world') return 'book';
|
|
24
|
+
if (cmd === 'paint' || cmd === 'layout' || /repaint|style/i.test(j.label)) return 'brush';
|
|
25
|
+
if (cmd === 'masks' || cmd === 'plates' || /mask|thing/i.test(j.label)) return 'scissors';
|
|
26
|
+
if (cmd === 'derive' || /floor|stand|walk/i.test(j.label)) return 'ruler';
|
|
27
|
+
if (cmd === 'sound' || cmd === 'voices' || /voice/i.test(j.label)) return 'sound';
|
|
28
|
+
if (cmd === 'icons') return 'bag';
|
|
29
|
+
if (cmd === 'export' || cmd === 'publish') return 'box';
|
|
30
|
+
return 'gear';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STEPS = new Set(['world', 'story', 'layout', 'paint', 'masks', 'plates', 'icons', 'derive', 'backdrop', 'weather', 'reactions', 'cast', 'model', 'sprites', 'sound', 'voices', 'faces', 'verify']);
|
|
34
|
+
|
|
35
|
+
const STATE: Record<string, { mark: string; word: string }> = {
|
|
36
|
+
queued: { mark: '⏳', word: 'waiting' },
|
|
37
|
+
running: { mark: '●', word: 'running' },
|
|
38
|
+
done: { mark: '✓', word: 'done' },
|
|
39
|
+
failed: { mark: '✕', word: 'failed' },
|
|
40
|
+
cancelled: { mark: '–', word: 'stopped' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const clock = (ms: number) => (ms < 60_000 ? `${Math.max(1, Math.round(ms / 1000))} s` : `${Math.floor(ms / 60_000)} min ${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')} s`);
|
|
44
|
+
const time = (at: number) => new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
45
|
+
/** An action as a person would say it: "Look at a place ×3". */
|
|
46
|
+
const said = (name: string) => SAYS[name] ?? name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
47
|
+
|
|
48
|
+
export function Activity({ lines, costs, all }: { lines: ActivityLine[]; costs: Record<string, Record<string, number>>; all: boolean }) {
|
|
49
|
+
const groups: Group[] = [];
|
|
50
|
+
const jobs = new Map<string, Extract<Group, { type: 'job' }>>();
|
|
51
|
+
for (const l of lines) {
|
|
52
|
+
const at = l.at ?? 0;
|
|
53
|
+
if (l.job) {
|
|
54
|
+
let g = jobs.get(l.job);
|
|
55
|
+
if (!g) {
|
|
56
|
+
// The click that started this job is the job's row now: drop it from the clicks just before.
|
|
57
|
+
const before = groups.at(-1);
|
|
58
|
+
const by = l.info?.by === 'agent' ? 'agent' : 'you';
|
|
59
|
+
if (before?.type === 'acts' && before.who === by && at - before.last < 3000) {
|
|
60
|
+
before.acts.pop();
|
|
61
|
+
if (!before.acts.length) groups.pop();
|
|
62
|
+
}
|
|
63
|
+
g = { type: 'job', id: l.job, info: l.info ?? { id: l.job, label: 'a job', args: [], state: 'running', by: 'user' }, lines: [], at };
|
|
64
|
+
jobs.set(l.job, g);
|
|
65
|
+
groups.push(g);
|
|
66
|
+
}
|
|
67
|
+
if (l.info) g.info = l.info;
|
|
68
|
+
else if (all || l.kind !== 'out') g.lines.push(l);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (l.act && l.who) {
|
|
72
|
+
const last = groups.at(-1);
|
|
73
|
+
if (last?.type === 'acts' && last.who === l.who) (last.acts.push(l.act), (last.last = at));
|
|
74
|
+
else groups.push({ type: 'acts', who: l.who, acts: [l.act], at, last: at });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (all || l.kind !== 'out') groups.push({ type: 'line', line: l, at });
|
|
78
|
+
}
|
|
79
|
+
if (!groups.length) return <p class="note">Nothing yet: builds, the agent's steps and your clicks show here as they happen.</p>;
|
|
80
|
+
return (
|
|
81
|
+
<ol class="activity">
|
|
82
|
+
{[...groups].reverse().map((g) => {
|
|
83
|
+
if (g.type === 'job') {
|
|
84
|
+
const j = g.info;
|
|
85
|
+
const st = STATE[j.state] ?? { mark: '·', word: j.state };
|
|
86
|
+
const took = j.startedAt ? (j.endedAt ? Date.parse(j.endedAt) : Date.now()) - Date.parse(j.startedAt) : 0;
|
|
87
|
+
const models = Object.entries(costs[j.id] ?? {});
|
|
88
|
+
return (
|
|
89
|
+
<li class={`a-job ${j.state}`} key={`j${j.id}`}>
|
|
90
|
+
<details open={j.state === 'running' || j.state === 'failed'}>
|
|
91
|
+
<summary>
|
|
92
|
+
<span class="a-sign"><Icon name={sign(j)} /></span>
|
|
93
|
+
<span class="a-what"><strong>{j.label}</strong><small>{j.by === 'agent' ? 'the agent' : 'you'} · {time(Date.parse(j.queuedAt ?? '') || g.at)}{took ? ` · ${clock(took)}` : ''}{models.length ? ` · ${models.map(([m, n]) => `${nowBy(MODEL_NAME[m] ?? m)} ${n}`).join(', ')}` : ''}</small></span>
|
|
94
|
+
<span class={`a-state ${j.state}`} title={st.word}>{st.mark} {st.word}</span>
|
|
95
|
+
{(g.lines.length > 0 || j.error) && <span class="a-more" aria-hidden="true" />}
|
|
96
|
+
</summary>
|
|
97
|
+
{j.error && <p class="a-error">{j.error}</p>}
|
|
98
|
+
{g.lines.length > 0 && <ul class="a-steps">{g.lines.map((l) => <li class={l.kind}>{l.text}</li>)}</ul>}
|
|
99
|
+
</details>
|
|
100
|
+
</li>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (g.type === 'acts') {
|
|
104
|
+
// Runs of the same action fold: "Look at a place ×3".
|
|
105
|
+
const runs: Array<[string, number]> = [];
|
|
106
|
+
for (const a of g.acts) runs.at(-1)?.[0] === a ? runs.at(-1)![1]++ : runs.push([a, 1]);
|
|
107
|
+
return (
|
|
108
|
+
<li class={`a-acts ${g.who}`} key={`a${g.at}-${g.acts.length}`}>
|
|
109
|
+
<span class="a-sign"><Icon name={g.who === 'agent' ? 'agent' : 'hand'} /></span>
|
|
110
|
+
<span class="a-what"><small>{g.who === 'agent' ? 'The agent' : 'You'} · {time(g.at)}</small><span>{runs.map(([a, n]) => `${said(a)}${n > 1 ? ` ×${n}` : ''}`).join(' · ')}</span></span>
|
|
111
|
+
</li>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const l = g.line;
|
|
115
|
+
return (
|
|
116
|
+
<li class={`a-line ${l.kind}`} key={`l${l.seq}`}>
|
|
117
|
+
<span class="a-sign">{l.kind === 'play' ? <Icon name="play" /> : l.kind === 'err' ? <Icon name="warning" /> : l.kind === 'agent' ? <Icon name="agent" /> : '·'}</span>
|
|
118
|
+
<span class="a-what"><span>{l.text.replace(/^play: /, '')}</span></span>
|
|
119
|
+
</li>
|
|
120
|
+
);
|
|
121
|
+
})}
|
|
122
|
+
</ol>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// The Muse Code panel: genaicode's own chat (genaicode/react), fed the session state the Studio server keeps
|
|
2
|
+
// for this world. The page holds no agent and no key: it sends what the person types and answers approvals.
|
|
3
|
+
import { AgentChat, type ChatNotice, type SessionState, type SessionTurn } from 'genaicode/react';
|
|
4
|
+
import type { ComponentChildren } from 'preact';
|
|
5
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
6
|
+
import { call, fileUrl, token } from './api.ts';
|
|
7
|
+
import { Camera } from './camera.tsx';
|
|
8
|
+
import { SayIt } from './say-it.tsx';
|
|
9
|
+
import { screenPrefix, shownScreen } from './world.tsx';
|
|
10
|
+
import { Icon } from './icons.tsx';
|
|
11
|
+
|
|
12
|
+
/** What each Studio tool does, as the person would say it. */
|
|
13
|
+
export const SAYS: Record<string, string> = {
|
|
14
|
+
world_export: 'Make a zip',
|
|
15
|
+
world_publish: 'Publish a link',
|
|
16
|
+
picture_view: 'Look at your picture',
|
|
17
|
+
world_list: 'List worlds', world_status: 'World status', place_status: 'Look at a place', job_list: 'List jobs',
|
|
18
|
+
job_wait: 'Wait for a job', job_cancel: 'Stop a job', job_retry: 'Run a job again', world_build: 'Build', run_step: 'Run a step',
|
|
19
|
+
corrections_get: 'Read fixes', corrections_clear: 'Take fixes back', mask_fix: 'Fix a mask', thing_remove: 'Remove a thing', thing_add: 'Add a thing', mask_brush: 'Brush a mask',
|
|
20
|
+
floor_cells: 'Fix floor cells', stand_set: 'Move a stand spot', repaint_region: 'Repaint a region', water_flow: 'Set water flow', water_area: 'Fix where water moves', fog_drift: 'Set fog drift', weather_set: 'Set weather', reactions_set: 'Set reactions', reactions_status: 'Scene reactions',
|
|
21
|
+
feedback_pending: 'Read open notes', feedback_list: 'Read notes', feedback_done: 'Close a note', feedback_add: 'Leave a note', feedback_reopen: 'Reopen a note',
|
|
22
|
+
history_list: 'Read history', history_undo: 'Undo a job', history_redo: 'Redo', lock_set: 'Change a lock', ui_focus: 'Show a place', ask_person: 'Ask you', budget: 'Check budget', setup_status: 'Check Setup',
|
|
23
|
+
cast_status: 'Look at the cast', cast_redo: 'Repaint a character', voice_list: 'List voices', voice_set: 'Change a voice',
|
|
24
|
+
story_status: 'Read the story', story_text: 'Reword a line', story_redo: 'Rewrite the story', place_view: 'Look at the floor', play_reload: 'Reload the game', npc_move: 'Move an NPC',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const auth = { authorization: `Bearer ${token}` };
|
|
28
|
+
|
|
29
|
+
/** Follows the world's conversation: one whole SessionState per change, reconnecting when the line drops. */
|
|
30
|
+
function useSession(slug: string): [SessionState | null, boolean] {
|
|
31
|
+
const [state, setState] = useState<SessionState | null>(null);
|
|
32
|
+
const [live, setLive] = useState(false);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!slug) return;
|
|
35
|
+
let stopped = false;
|
|
36
|
+
let ctrl: AbortController | null = null;
|
|
37
|
+
(async () => {
|
|
38
|
+
while (!stopped) {
|
|
39
|
+
ctrl = new AbortController();
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`/api/agent/${slug}/live`, { headers: auth, signal: ctrl.signal });
|
|
42
|
+
if (!res.ok || !res.body) throw new Error(String(res.status));
|
|
43
|
+
setLive(true);
|
|
44
|
+
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
45
|
+
let buf = '';
|
|
46
|
+
for (;;) {
|
|
47
|
+
const { value, done } = await reader.read();
|
|
48
|
+
if (done) break;
|
|
49
|
+
buf += value;
|
|
50
|
+
let cut;
|
|
51
|
+
while ((cut = buf.indexOf('\n\n')) >= 0) {
|
|
52
|
+
const data = buf.slice(0, cut).split('\n').find((l) => l.startsWith('data: '));
|
|
53
|
+
buf = buf.slice(cut + 2);
|
|
54
|
+
if (data) setState(JSON.parse(data.slice(6)) as SessionState | null);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
setLive(false);
|
|
59
|
+
if (!stopped) await new Promise((r) => setTimeout(r, 1500));
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
return () => {
|
|
63
|
+
stopped = true;
|
|
64
|
+
ctrl?.abort();
|
|
65
|
+
};
|
|
66
|
+
}, [slug]);
|
|
67
|
+
return [state, live];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Recorded = { at: string; type: string; agent?: string; status?: SessionState['status']; turn?: SessionState['turns'][number] };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Plays a recorded conversation back (studio/sessions/<file>), at `speed` times real time, for the film:
|
|
74
|
+
* the chat grows as it did, steps and all. Long pauses are cut to two seconds.
|
|
75
|
+
*/
|
|
76
|
+
function useReplay(slug: string, file: string | null, speed: number): [SessionState | null, boolean] {
|
|
77
|
+
const [state, setState] = useState<SessionState | null>(null);
|
|
78
|
+
const [done, setDone] = useState(false);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!file) return;
|
|
81
|
+
let stopped = false;
|
|
82
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
83
|
+
fetch(fileUrl(slug, `studio/sessions/${file}`)).then((r) => r.text()).then((text) => {
|
|
84
|
+
const lines = text.split('\n').filter(Boolean).map((l) => JSON.parse(l) as Recorded).filter((l) => l.type === 'state' && l.turn);
|
|
85
|
+
const turns = new Map<number, SessionState['turns'][number]>();
|
|
86
|
+
let clock = 0;
|
|
87
|
+
let last = lines[0] ? Date.parse(lines[0].at) : 0;
|
|
88
|
+
lines.forEach((l, i) => {
|
|
89
|
+
clock += Math.min(2000, Date.parse(l.at) - last) / speed;
|
|
90
|
+
last = Date.parse(l.at);
|
|
91
|
+
timers.push(setTimeout(() => {
|
|
92
|
+
if (stopped) return;
|
|
93
|
+
turns.set(l.turn!.id, l.turn!);
|
|
94
|
+
// The recording can end inside a turn; once it is played through, nothing is working any more.
|
|
95
|
+
setState({ agent: l.agent ?? 'muse', cwd: '', status: i === lines.length - 1 ? 'idle' : l.status ?? 'idle', turns: [...turns.values()], queued: [], approvals: [], usage: {}, canSteer: false });
|
|
96
|
+
if (i === lines.length - 1) setDone(true);
|
|
97
|
+
}, 600 + clock));
|
|
98
|
+
});
|
|
99
|
+
}).catch(() => setDone(true));
|
|
100
|
+
return () => {
|
|
101
|
+
stopped = true;
|
|
102
|
+
timers.forEach(clearTimeout);
|
|
103
|
+
};
|
|
104
|
+
}, [slug, file, speed]);
|
|
105
|
+
return [state, done];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type Offer = { name: string; label: string; agent?: string; installed: boolean; using: boolean };
|
|
109
|
+
|
|
110
|
+
type Session = { file: string; started: string | null; agents: string[]; prompts: string[]; turns: number; replayable: boolean };
|
|
111
|
+
|
|
112
|
+
type JobInfo = { id: string; label: string; state: string; step?: string; by: string; args?: string[]; startedAt?: string };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* genaicode's chat shows a tool's argument only for the usual keys (command, path, description...), so a
|
|
116
|
+
* Studio tool would show as a bare name ("Wait for a job"). Each Studio call gets a description: the job's
|
|
117
|
+
* name and step for the job tools, the world, place and thing for the rest; a running job_wait says where it is.
|
|
118
|
+
*/
|
|
119
|
+
export function describeCalls(state: SessionState | null, jobs: readonly JobInfo[]): SessionState | null {
|
|
120
|
+
if (!state) return state;
|
|
121
|
+
const job = (id: unknown) => jobs.find((j) => j.id === id);
|
|
122
|
+
const turns = state.turns.map((turn) => ({
|
|
123
|
+
...turn,
|
|
124
|
+
// An approved Studio call is followed by the call itself, which says the same; only a pending or denied one is news.
|
|
125
|
+
entries: turn.entries.filter((e) => !(e.kind === 'approval' && e.decision === 'approve' && e.request.summary?.includes('mcp__diorama__'))).map((e) => {
|
|
126
|
+
// An approval names the raw tool; say it the way the steps do.
|
|
127
|
+
if (e.kind === 'approval' && e.request.summary?.includes('mcp__diorama__')) {
|
|
128
|
+
return { ...e, request: { ...e.request, summary: e.request.summary.replace(/mcp__diorama__(\w+)/g, (_, name: string) => SAYS[name] ?? name.replace(/_/g, ' ')) } };
|
|
129
|
+
}
|
|
130
|
+
// The agent's own tool, refused or failed with nothing to say: say so, not "No output."
|
|
131
|
+
if (e.kind === 'tool' && e.done && e.isError && !e.output?.trim() && !e.name.startsWith('mcp__diorama__')) return { ...e, output: 'Refused or failed; the agent carries on.' };
|
|
132
|
+
if (e.kind !== 'tool' || !e.name.startsWith('mcp__diorama__') || !e.input || typeof e.input !== 'object') return e;
|
|
133
|
+
const input = e.input as Record<string, unknown>;
|
|
134
|
+
const j = job(input.id);
|
|
135
|
+
const where = [input.slug, input.place, input.thing ?? input.npc ?? input.key ?? input.step, input.path].filter((v) => typeof v === 'string').join(' · ');
|
|
136
|
+
const description = j ? `${j.label}${j.state === 'running' && j.step ? ` · ${j.step}` : ` · ${j.state}`}` : where;
|
|
137
|
+
// A running wait says which job, which step and for how long, live: the person sees what they wait for.
|
|
138
|
+
const took = j?.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(j.startedAt)) / 1000)) : undefined;
|
|
139
|
+
const at = j?.step && j.args ? steps(j.args).indexOf(j.step) : -1;
|
|
140
|
+
const output = !e.done && j && (e.name.endsWith('job_wait') || e.name.endsWith('run_step'))
|
|
141
|
+
? `${j.label}: ${j.state}${j.step ? `, at ${j.step}${at >= 0 ? ` (${at + 1}/${steps(j.args!).length})` : ''}` : ''}${took !== undefined ? ` · ${clock(took)}` : ''} (started by ${j.by === 'agent' ? 'the agent' : 'you'})`
|
|
142
|
+
: e.output;
|
|
143
|
+
return description ? { ...e, input: { ...input, description }, output } : e;
|
|
144
|
+
}),
|
|
145
|
+
}));
|
|
146
|
+
return { ...state, turns };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const BUILD_STEPS = ['world', 'story', 'layout', 'paint', 'masks', 'plates', 'icons', 'derive', 'backdrop', 'weather', 'reactions', 'cast', 'model', 'sprites', 'sound', 'voices', 'faces', 'verify'];
|
|
150
|
+
/** The steps a job runs, from its command: one step, a range (--from/--to) or a whole build. */
|
|
151
|
+
function steps(args: readonly string[]): string[] {
|
|
152
|
+
if (args[0] !== 'build') return BUILD_STEPS.includes(args[0]) ? [args[0]] : [];
|
|
153
|
+
const flag = (name: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
154
|
+
const from = BUILD_STEPS.indexOf(flag('--from') ?? 'world');
|
|
155
|
+
const to = BUILD_STEPS.indexOf(flag('--to') ?? 'verify');
|
|
156
|
+
return BUILD_STEPS.slice(Math.max(0, from), to < 0 ? undefined : to + 1);
|
|
157
|
+
}
|
|
158
|
+
const clock = (s: number) => (s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`);
|
|
159
|
+
|
|
160
|
+
/** In the atelier the panel is the world's conversation: cards before and between turns, and what the stage has open. */
|
|
161
|
+
type Atelier = { pinned?: ComponentChildren; notices?: readonly ChatNotice[]; afterTurn?: (turn: SessionTurn, index: number, last: boolean) => ComponentChildren; footer?: (agentBusy: boolean) => ComponentChildren; onScreen?: string | null; empty?: ComponentChildren };
|
|
162
|
+
|
|
163
|
+
export function AgentPanel({ slug, replay, speed = 1, jobs = [], notices, afterTurn, footer, onScreen, empty, pinned }: { slug: string; replay?: string | null; speed?: number; jobs?: readonly JobInfo[] } & Atelier) {
|
|
164
|
+
const [liveState, live] = useSession(replay ? '' : slug);
|
|
165
|
+
const [replayed, replayDone] = useReplay(slug, replay ?? null, speed);
|
|
166
|
+
const state = describeCalls(replay ? replayed : liveState, jobs);
|
|
167
|
+
const [sessions, setSessions] = useState<Session[] | null>(null);
|
|
168
|
+
const [error, setError] = useState('');
|
|
169
|
+
const [offers, setOffers] = useState<Offer[]>([]);
|
|
170
|
+
const loadOffers = () => fetch(`/api/agent/${slug}/agents`, { headers: auth }).then((r) => r.json()).then(setOffers).catch(() => {});
|
|
171
|
+
useEffect(() => void (replay || loadOffers()), [slug]);
|
|
172
|
+
const current = offers.find((o) => o.using);
|
|
173
|
+
const running = state?.status === 'running';
|
|
174
|
+
// Before the first message the agent is a choice; after it, the conversation is handed over instead.
|
|
175
|
+
const talked = (state?.turns.length ?? 0) > 0;
|
|
176
|
+
const others = offers.filter((o) => o.installed && !o.using);
|
|
177
|
+
const labelOf = (agent: string) => offers.find((o) => o.agent === agent || o.name === agent)?.label ?? agent;
|
|
178
|
+
const post = async (verb: string, body: object = {}) => {
|
|
179
|
+
setError('');
|
|
180
|
+
const res = await fetch(`/api/agent/${slug}/${verb}`, { method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
181
|
+
if (!res.ok) setError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? `could not send (${res.status})`);
|
|
182
|
+
return res.ok;
|
|
183
|
+
};
|
|
184
|
+
// Pictures for the next message: pasted, dropped, picked or taken with the webcam, kept with the world.
|
|
185
|
+
const [pics, setPics] = useState<string[]>([]);
|
|
186
|
+
const [adding, setAdding] = useState(0);
|
|
187
|
+
// What was said goes into the chat's own box (new message or, while the agent works, a steer), not sent yet.
|
|
188
|
+
const said = (text: string) => {
|
|
189
|
+
const box = panel.current?.querySelector<HTMLTextAreaElement>('textarea');
|
|
190
|
+
if (!box) return;
|
|
191
|
+
const value = `${box.value.trim()} ${text}`.trim();
|
|
192
|
+
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set?.call(box, value);
|
|
193
|
+
box.dispatchEvent(new Event('input', { bubbles: true }));
|
|
194
|
+
box.focus();
|
|
195
|
+
box.setSelectionRange(value.length, value.length);
|
|
196
|
+
};
|
|
197
|
+
const [camera, setCamera] = useState(false);
|
|
198
|
+
const picker = useRef<HTMLInputElement>(null);
|
|
199
|
+
const keep = async (name: string, data: string) => {
|
|
200
|
+
setAdding((n) => n + 1);
|
|
201
|
+
try {
|
|
202
|
+
const { file } = await call<{ file: string }>('picture_add', { slug, name, data });
|
|
203
|
+
setPics((p) => [...p, file]);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
setError((e as Error).message);
|
|
206
|
+
}
|
|
207
|
+
setAdding((n) => n - 1);
|
|
208
|
+
};
|
|
209
|
+
const addFiles = (files: Iterable<File>) => {
|
|
210
|
+
for (const f of files) if (f.type.startsWith('image/')) void dataUrl(f).then((d) => keep(f.name || 'pasted.png', d));
|
|
211
|
+
};
|
|
212
|
+
const images = (list: DataTransfer | null) => [...(list?.files ?? [])].filter((f) => f.type.startsWith('image/'));
|
|
213
|
+
const send = (text: string) => {
|
|
214
|
+
const looked = onScreen ? screenPrefix(onScreen) + text : text;
|
|
215
|
+
const said = pics.length ? `[Pictures: ${pics.join(', ')}]\n${looked}` : looked;
|
|
216
|
+
setPics([]);
|
|
217
|
+
void post('say', { text: said });
|
|
218
|
+
};
|
|
219
|
+
const choose = async (name: string) => {
|
|
220
|
+
if (await post('use', { name })) void loadOffers();
|
|
221
|
+
};
|
|
222
|
+
// A view tool's step shows the picture it looked at, as a thumbnail that opens big. genaicode renders the steps,
|
|
223
|
+
// so the thumbnails are laid on after each render: a step whose output names a picture gets one in its line.
|
|
224
|
+
const panel = useRef<HTMLElement>(null);
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
const root = panel.current;
|
|
227
|
+
if (!root) return;
|
|
228
|
+
const lay = () => {
|
|
229
|
+
for (const pre of root.querySelectorAll<HTMLElement>('details.gc-tool > pre')) {
|
|
230
|
+
const file = /^\{"picture":"([^"]+)"/.exec(pre.textContent ?? '')?.[1];
|
|
231
|
+
const summary = pre.parentElement?.querySelector('summary');
|
|
232
|
+
if (!file || !summary || summary.querySelector('.tool-pic')) continue;
|
|
233
|
+
const url = fileUrl(slug, file, Date.now());
|
|
234
|
+
const b = document.createElement('button');
|
|
235
|
+
b.type = 'button';
|
|
236
|
+
b.className = 'tool-pic';
|
|
237
|
+
b.dataset.lightbox = url;
|
|
238
|
+
b.title = 'What the agent looked at. Click to see it big';
|
|
239
|
+
b.innerHTML = `<img alt="" src="${url}">`;
|
|
240
|
+
summary.appendChild(b);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
lay();
|
|
244
|
+
const watch = new MutationObserver(lay);
|
|
245
|
+
watch.observe(root, { childList: true, subtree: true, characterData: true });
|
|
246
|
+
return () => watch.disconnect();
|
|
247
|
+
}, [slug]);
|
|
248
|
+
// The world's opening cards scrolled out of sight in a long talk: pin the world strip under the header.
|
|
249
|
+
const [away, setAway] = useState(false);
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const scroller = panel.current?.querySelector<HTMLElement>('.gc-scroll');
|
|
252
|
+
if (!scroller || !pinned) return;
|
|
253
|
+
const check = () => {
|
|
254
|
+
const cards = scroller.querySelectorAll('[data-opening]');
|
|
255
|
+
const top = scroller.getBoundingClientRect().top;
|
|
256
|
+
setAway(cards.length > 0 && [...cards].every((c) => c.getBoundingClientRect().bottom < top + 8));
|
|
257
|
+
};
|
|
258
|
+
check();
|
|
259
|
+
scroller.addEventListener('scroll', check, { passive: true });
|
|
260
|
+
const watch = new MutationObserver(check);
|
|
261
|
+
watch.observe(scroller, { childList: true, subtree: true });
|
|
262
|
+
return () => (scroller.removeEventListener('scroll', check), watch.disconnect());
|
|
263
|
+
}, [slug, !!pinned, !!state]);
|
|
264
|
+
return (
|
|
265
|
+
<section
|
|
266
|
+
ref={panel}
|
|
267
|
+
class="agent"
|
|
268
|
+
onPaste={(e) => {
|
|
269
|
+
const found = images(e.clipboardData);
|
|
270
|
+
if (found.length && !replay) (e.preventDefault(), addFiles(found));
|
|
271
|
+
}}
|
|
272
|
+
onDragOver={(e) => images(e.dataTransfer).length || e.dataTransfer?.types.includes('Files') ? e.preventDefault() : undefined}
|
|
273
|
+
onDrop={(e) => {
|
|
274
|
+
const found = images(e.dataTransfer);
|
|
275
|
+
if (found.length && !replay) (e.preventDefault(), addFiles(found));
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
<AgentChat
|
|
279
|
+
state={state}
|
|
280
|
+
onSend={send}
|
|
281
|
+
onStop={() => void post('stop')}
|
|
282
|
+
onApprove={(id, decision) => void post('approval', { id, decision })}
|
|
283
|
+
formatToolName={(name) => (name.startsWith('mcp__diorama__') ? SAYS[name.slice(14)] ?? name.slice(14).replace(/_/g, ' ') : name)}
|
|
284
|
+
foldSteps
|
|
285
|
+
renderPrompt={(text) => shownPrompt(text, slug)}
|
|
286
|
+
notices={notices}
|
|
287
|
+
renderAfterTurn={(turn: SessionTurn, index: number) => {
|
|
288
|
+
const next = state?.turns[index + 1];
|
|
289
|
+
const handover = next && next.agent !== turn.agent ? <p class="handover">{labelOf(turn.agent)} handed over to {labelOf(next.agent)}</p> : null;
|
|
290
|
+
const after = afterTurn?.(turn, index, index === (state?.turns.length ?? 0) - 1);
|
|
291
|
+
return (handover || after ? <>{after}{handover}</> : null) as never;
|
|
292
|
+
}}
|
|
293
|
+
renderFooter={footer && (() => footer(running) as never)}
|
|
294
|
+
placeholder={running ? undefined : 'Ask for a change…'}
|
|
295
|
+
theme="light"
|
|
296
|
+
disabled={!!replay || !live}
|
|
297
|
+
header={
|
|
298
|
+
<>
|
|
299
|
+
<div class="row">
|
|
300
|
+
{offers.length > 1 && !talked
|
|
301
|
+
? <select class="agent-pick" aria-label="Agent" title="Who to talk to. Your pick is remembered for new worlds; once you have talked, you can hand the conversation over to another agent" disabled={running} value={current?.name} onChange={(e) => void choose(e.currentTarget.value)}>
|
|
302
|
+
{offers.map((o) => <option value={o.name} disabled={!o.installed}>{o.label}{o.installed ? '' : ' (not installed)'}</option>)}
|
|
303
|
+
</select>
|
|
304
|
+
: <h2>{current?.label ?? 'Muse Code'}</h2>}
|
|
305
|
+
{talked && !replay && others.length > 0 && (
|
|
306
|
+
<select class="agent-hand" aria-label="Hand over to another agent" title={running ? 'Stop the agent or wait to hand over' : 'Another agent takes over this conversation: it reads everything said so far first'} disabled={running} value="" onChange={(e) => void choose(e.currentTarget.value)}>
|
|
307
|
+
<option value="" disabled>Hand over…</option>
|
|
308
|
+
{others.map((o) => <option value={o.name}>to {o.label}</option>)}
|
|
309
|
+
</select>
|
|
310
|
+
)}
|
|
311
|
+
<span class="grow" />
|
|
312
|
+
{replay
|
|
313
|
+
? <span class="live on">{replayDone ? 'replay done' : 'replay'}</span>
|
|
314
|
+
: <span class={`live ${live ? 'on' : ''}`}>{running ? 'working…' : live ? 'ready' : 'reconnecting'}</span>}
|
|
315
|
+
</div>
|
|
316
|
+
{away && pinned}
|
|
317
|
+
</>
|
|
318
|
+
}
|
|
319
|
+
emptyState={empty ?? <p class="note">Ask for a world, a fix or a check. Each step shows here and in Activity, and Undo takes back everything the agent did in its last turn.</p>}
|
|
320
|
+
hint={replay ? null : (
|
|
321
|
+
<div class="pics">
|
|
322
|
+
{pics.map((f) => (
|
|
323
|
+
<span class="pic">
|
|
324
|
+
<img src={fileUrl(slug, f)} alt="" />
|
|
325
|
+
<button type="button" class="ghost small" aria-label="Take this picture out" title="Take this picture out" onClick={() => setPics((p) => p.filter((x) => x !== f))}>×</button>
|
|
326
|
+
</span>
|
|
327
|
+
))}
|
|
328
|
+
{adding > 0 && <span class="detail">adding…</span>}
|
|
329
|
+
<button type="button" class="ghost small" title="Show the agent a picture with your message: a drawing, a photo, a reference. Or paste one (Cmd+V) or drop it here" onClick={() => picker.current?.click()}><Icon name="figure" /> Picture</button>
|
|
330
|
+
<button type="button" class="ghost small" title={camera ? 'Close the camera' : 'Take a webcam photo (a drawing held up works) to send the agent with your message'} onClick={() => setCamera(!camera)}>{camera ? 'Close the camera' : <><Icon name="camera" /> Camera</>}</button>
|
|
331
|
+
<SayIt small tip={running ? 'Say what to change while the agent works: it is written into the box, Send steers the agent. Muse Voice Transcribe, 1 call' : 'Say your message instead of typing it: it is written into the box, you send it. Muse Voice Transcribe, 1 call'} onText={said} onError={setError} />
|
|
332
|
+
<input ref={picker} type="file" accept="image/*" multiple hidden onChange={(e) => (addFiles(e.currentTarget.files ?? []), (e.currentTarget.value = ''))} />
|
|
333
|
+
{error && <span class="error">{error}</span>}
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
/>
|
|
337
|
+
{camera && <Camera tip="Show the agent what you mean." onSnap={(d) => (void keep('photo.jpg', d), setCamera(false))} onClose={() => setCamera(false)} />}
|
|
338
|
+
{!replay && (
|
|
339
|
+
<details class="replays" onToggle={(e) => (e.currentTarget as HTMLDetailsElement).open && call<Session[]>('agent_sessions', { slug }).then(setSessions).catch(() => {})}>
|
|
340
|
+
<summary title="Every conversation with the agent is recorded; play one back as it happened (for the film)">Recorded sessions</summary>
|
|
341
|
+
{sessions && sessions.filter((x) => x.replayable).length === 0 && <p class="note">None yet: every conversation is recorded from now on.</p>}
|
|
342
|
+
<ul>{sessions?.filter((x) => x.replayable).map((x) => (
|
|
343
|
+
<li><a title="Play this session back as it happened. Free, nothing is run again" href={`/w/${slug}?replay=${encodeURIComponent(x.file)}`}>{x.started?.slice(0, 16).replace('T', ' ')}</a> <span class="detail">{x.agents.join(', ')} · {x.turns} turn{x.turns === 1 ? '' : 's'}{x.prompts[0] ? ` · ${x.prompts[0]}` : ''}</span></li>
|
|
344
|
+
))}</ul>
|
|
345
|
+
</details>
|
|
346
|
+
)}
|
|
347
|
+
</section>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const dataUrl = (f: Blob) => new Promise<string>((resolve, reject) => {
|
|
352
|
+
const r = new FileReader();
|
|
353
|
+
r.onload = () => resolve(String(r.result));
|
|
354
|
+
r.onerror = () => reject(r.error);
|
|
355
|
+
r.readAsDataURL(f);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/** A sent prompt as the person sees it: the pictures it carried as thumbnails, then their words. */
|
|
359
|
+
function shownPrompt(text: string, slug: string) {
|
|
360
|
+
// A Studio button's ask (Fix it, Ask the agent): its gist, and the whole wording folded under it.
|
|
361
|
+
const asked = /^\[Ask: ([^\]\n]+)\]\n/.exec(text);
|
|
362
|
+
if (asked) return <details class="ask-note"><summary>{asked[1]}</summary><p>{text.slice(asked[0].length)}</p></details>;
|
|
363
|
+
const m = /^\[Pictures: ([^\]]+)\]\n?/.exec(text);
|
|
364
|
+
const rest = shownScreen(m ? text.slice(m[0].length) : text);
|
|
365
|
+
if (!m && rest === text) return studioNote(text);
|
|
366
|
+
return (
|
|
367
|
+
<>
|
|
368
|
+
{m && <span class="pics sent">{m[1].split(', ').map((f) => <button type="button" class="pic-open" data-lightbox={fileUrl(slug, f)} title="See it big"><img src={fileUrl(slug, f)} alt="" /></button>)}</span>}
|
|
369
|
+
{rest}
|
|
370
|
+
</>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// The Studio's own words to the agent (a nudge, a repair wake) are not the person's: show them as a quiet note.
|
|
375
|
+
function studioNote(text: string) {
|
|
376
|
+
if (!text.startsWith('[Studio]')) return text;
|
|
377
|
+
const body = text.slice(8).trim();
|
|
378
|
+
const gist = body.startsWith('You are using the shell')
|
|
379
|
+
? 'Studio reminded the agent to look and fix with the Studio tools, not the shell.'
|
|
380
|
+
: body.startsWith('The build')
|
|
381
|
+
? 'Studio asked the agent to repair the places a build left unplayable.'
|
|
382
|
+
: 'Studio told the agent:';
|
|
383
|
+
return <details class="studio-note"><summary>{gist}</summary>{body}</details>;
|
|
384
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The page's only way to the server: actions, and the live feed. The token arrives once in the URL fragment,
|
|
2
|
+
// moves to sessionStorage and leaves the address bar.
|
|
3
|
+
|
|
4
|
+
export type StudioEvent = { seq: number; at: string; type: string; world?: string; [k: string]: unknown };
|
|
5
|
+
export type ActionInfo = { name: string; description: string; job: boolean; reads: boolean; input: { properties?: Record<string, { enum?: string[] }> } };
|
|
6
|
+
|
|
7
|
+
const KEY = 'diorama-studio-token';
|
|
8
|
+
|
|
9
|
+
function takeToken(): string | null {
|
|
10
|
+
const fromHash = location.hash.slice(1);
|
|
11
|
+
if (fromHash) {
|
|
12
|
+
try {
|
|
13
|
+
sessionStorage.setItem(KEY, fromHash);
|
|
14
|
+
} catch {}
|
|
15
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
16
|
+
return fromHash;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return sessionStorage.getItem(KEY);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const token = takeToken();
|
|
26
|
+
|
|
27
|
+
const headers = (): Record<string, string> => ({ authorization: `Bearer ${token}`, 'content-type': 'application/json' });
|
|
28
|
+
|
|
29
|
+
export class ApiError extends Error {
|
|
30
|
+
constructor(readonly status: number, message: string) {
|
|
31
|
+
super(message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Swaps the token for the cookie that lets <img> load world files. */
|
|
36
|
+
export async function openSession(): Promise<void> {
|
|
37
|
+
const res = await fetch('/api/session', { method: 'POST', headers: headers(), body: '{}' });
|
|
38
|
+
if (!res.ok) throw new ApiError(res.status, res.status === 401 ? 'This link has expired: open the one diorama studio printed in your terminal.' : `session: ${res.status}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function listActions(): Promise<ActionInfo[]> {
|
|
42
|
+
const res = await fetch('/api/actions', { headers: headers() });
|
|
43
|
+
if (!res.ok) throw new ApiError(res.status, `actions: ${res.status}`);
|
|
44
|
+
return res.json();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function call<T = unknown>(name: string, input: object = {}): Promise<T> {
|
|
48
|
+
const res = await fetch(`/api/actions/${name}`, { method: 'POST', headers: headers(), body: JSON.stringify(input) });
|
|
49
|
+
const body = (await res.json().catch(() => ({}))) as { result?: T; error?: string };
|
|
50
|
+
if (!res.ok) throw new ApiError(res.status, body.error ?? `${name}: ${res.status}`);
|
|
51
|
+
return body.result as T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Follows the live feed (one world, or studio-wide), reconnecting where it left off. Returns a stop function. */
|
|
55
|
+
export function follow(world: string | null, onEvent: (e: StudioEvent) => void, onState?: (live: boolean) => void): () => void {
|
|
56
|
+
let stopped = false;
|
|
57
|
+
let since = 0;
|
|
58
|
+
let ctrl: AbortController | null = null;
|
|
59
|
+
(async () => {
|
|
60
|
+
while (!stopped) {
|
|
61
|
+
ctrl = new AbortController();
|
|
62
|
+
try {
|
|
63
|
+
const q = new URLSearchParams({ since: String(since), ...(world && { world }) });
|
|
64
|
+
const res = await fetch(`/api/events?${q}`, { headers: { authorization: `Bearer ${token}` }, signal: ctrl.signal });
|
|
65
|
+
if (!res.ok || !res.body) throw new Error(String(res.status));
|
|
66
|
+
onState?.(true);
|
|
67
|
+
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
68
|
+
let buf = '';
|
|
69
|
+
for (;;) {
|
|
70
|
+
const { value, done } = await reader.read();
|
|
71
|
+
if (done) break;
|
|
72
|
+
buf += value;
|
|
73
|
+
let cut;
|
|
74
|
+
while ((cut = buf.indexOf('\n\n')) >= 0) {
|
|
75
|
+
const block = buf.slice(0, cut);
|
|
76
|
+
buf = buf.slice(cut + 2);
|
|
77
|
+
const data = block.split('\n').find((l) => l.startsWith('data: '));
|
|
78
|
+
if (!data) continue;
|
|
79
|
+
const event = JSON.parse(data.slice(6)) as StudioEvent;
|
|
80
|
+
since = Math.max(since, event.seq);
|
|
81
|
+
onEvent(event);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
onState?.(false);
|
|
86
|
+
if (!stopped) await new Promise((r) => setTimeout(r, 1500));
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
return () => {
|
|
90
|
+
stopped = true;
|
|
91
|
+
ctrl?.abort();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const fileUrl = (world: string, file: string, v?: number | null) => `/files/${world}/${file}${v ? `?v=${v}` : ''}`;
|
|
96
|
+
|
|
97
|
+
/** Says something to the world's Muse Code agent, as if typed in its panel. */
|
|
98
|
+
/** A Studio button's ask: `gist` is what the chat shows (the whole ask folds under it); the agent reads both. */
|
|
99
|
+
export async function askAgent(slug: string, text: string, gist?: string): Promise<void> {
|
|
100
|
+
if (gist) text = `[Ask: ${gist.replace(/[\]\n]/g, ' ')}]\n${text}`;
|
|
101
|
+
const res = await fetch(`/api/agent/${slug}/say`, { method: 'POST', headers: headers(), body: JSON.stringify({ text }) });
|
|
102
|
+
if (!res.ok) throw new ApiError(res.status, ((await res.json().catch(() => ({}))) as { error?: string }).error ?? `could not send (${res.status})`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type AgentDefaults = { env: string | null; agents: Array<{ name: string; label: string; installed: boolean; default: boolean }> };
|
|
106
|
+
/** Setup: the agent new worlds start with; `name` sets it. */
|
|
107
|
+
export async function agentDefault(name?: string): Promise<AgentDefaults> {
|
|
108
|
+
const res = await fetch('/api/agent-default', { method: name ? 'POST' : 'GET', headers: headers(), ...(name && { body: JSON.stringify({ name }) }) });
|
|
109
|
+
if (!res.ok) throw new ApiError(res.status, ((await res.json().catch(() => ({}))) as { error?: string }).error ?? `could not reach the Studio (${res.status})`);
|
|
110
|
+
return (await res.json()) as AgentDefaults;
|
|
111
|
+
}
|