cozyclay 3.1.6 → 3.2.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 +145 -0
- package/INSTALL_FOR_AGENTS.md +4 -4
- package/README.md +4 -2
- package/bin/agent/agent-failure-codes.mjs +34 -8
- package/bin/agent/agent-routes.mjs +6 -6
- package/bin/agent/agent-runner.mjs +25 -17
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -1
- package/bin/agent/openai-compatible-provider.mjs +24 -1
- package/bin/agent/studio-prompt.mjs +2 -2
- package/bin/agent/supervisor-followup.mjs +34 -0
- package/bin/agent/supervisor.mjs +6 -6
- package/bin/cozyclay.mjs +23 -60
- package/bin/star-prompt.mjs +15 -14
- package/bin/studio-routes.mjs +6 -3
- package/bin/telemetry-state.mjs +5 -7
- package/bin/update-check.mjs +61 -7
- package/bin/update-install.mjs +16 -3
- package/bin/update-restart.mjs +95 -0
- package/bin/update-routes.mjs +25 -3
- package/dist/app/index.html +13 -7
- package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
- package/dist/assets/analytics-CaTUuLPM.js +1 -0
- package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
- package/dist/assets/landing-2Fy7DU41.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
- package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
- package/dist/assets/task-board-UVGTZLRF.js +15 -0
- package/dist/assets/workflow-CSiBlX1s.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +4 -1
- package/dist/privacy/index.html +9 -6
- package/dist/sitemap.xml +2 -2
- package/dist/workflow/index.html +5 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +4 -4
- package/mcp/tool-handlers.mjs +14 -16
- package/package.json +3 -1
- package/src/App.jsx +42 -75
- package/src/analytics-controls.js +25 -3
- package/src/analytics-strict.js +16 -4
- package/src/analytics.js +32 -26
- package/src/app-version.js +13 -0
- package/src/asset-drop.js +39 -0
- package/src/asset-pane.css +34 -0
- package/src/asset-pane.jsx +4 -35
- package/src/bus-tasks.js +1 -1
- package/src/character-kind.js +8 -6
- package/src/click-friction.js +5 -4
- package/src/command-bus-telemetry.js +8 -7
- package/src/command-bus.js +2 -16
- package/src/commands/cast.js +2 -1
- package/src/commands/index.js +2 -1
- package/src/commands/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- package/src/commands/physics.js +21 -0
- package/src/commands/project.js +3 -3
- package/src/commands/sculpt-compare.js +53 -0
- package/src/content-tile.jsx +36 -0
- package/src/domains/cast.js +7 -6
- package/src/domains/motion.js +13 -14
- package/src/domains/scenes.js +41 -31
- package/src/execution-telemetry.js +7 -3
- package/src/export-menu-telemetry.js +26 -0
- package/src/finger-handles.jsx +128 -0
- package/src/friction-hint.js +82 -0
- package/src/live-control.js +2 -1
- package/src/locale-zh/panels.js +20 -0
- package/src/locale-zh/shell.js +4 -0
- package/src/locale-zh/workspace.js +6 -0
- package/src/motion/center-of-mass.js +5 -23
- package/src/motion/contact-project.js +143 -0
- package/src/motion/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/fix-collisions.js +1 -1
- package/src/motion/ik-drag.js +93 -1
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- package/src/motion/timeline.css +12 -0
- package/src/motion/timeline.jsx +2 -2
- package/src/motion3/compile-job.js +3 -2
- package/src/motion3/compiler/compile.js +3 -3
- package/src/motion3/compiler/head-lead.js +4 -2
- package/src/motion3/compiler/spring.js +95 -0
- package/src/motion3/compiler/validate-objects.js +18 -2
- package/src/motion3/compiler/validate-parts.js +1 -0
- package/src/motion3/compiler/vocab.js +1 -1
- package/src/motion3/fix/index.js +8 -4
- package/src/motion3/fix/person.js +110 -0
- package/src/motion3/free-motion.js +49 -11
- package/src/motion3/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- package/src/motion3/object-pose.js +44 -2
- package/src/motion3/people.js +16 -1
- package/src/motion3/physics-frames.js +72 -0
- package/src/motion3/physics-trail.js +45 -0
- package/src/motion3/physics-view.js +79 -0
- package/src/motion3/pipeline.js +5 -1
- package/src/motion3/qa/ballistic.js +67 -0
- package/src/motion3/qa/com.js +140 -0
- package/src/motion3/qa/ground.js +10 -3
- package/src/motion3/qa/hints-contact.js +21 -7
- package/src/motion3/qa/hints-motion.js +38 -1
- package/src/motion3/qa/key-checks.js +5 -4
- package/src/motion3/qa/patch.js +32 -4
- package/src/motion3/qa/people-checks.js +1 -1
- package/src/motion3/qa/spring-pivot.js +23 -0
- package/src/motion3/qa/style.js +13 -30
- package/src/motion3/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- package/src/motion3/schema/fields.js +4 -2
- package/src/motion3/schema/topics.js +15 -11
- package/src/motion3/schema/types.js +13 -9
- package/src/motion3/skills/catch.js +2 -1
- package/src/panels/FingerPanel.jsx +38 -0
- package/src/panels/RigControlPanel.jsx +9 -1
- package/src/panels/SculptPanel.jsx +12 -12
- package/src/panels/SculptPartTree.jsx +46 -0
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/panels/sculpt.css +11 -0
- package/src/physics-overlay-settings.js +41 -0
- package/src/physics-overlay.js +152 -0
- package/src/physics-overlay.jsx +54 -0
- package/src/physics-toggle.jsx +20 -0
- package/src/pose-fade.js +7 -3
- package/src/posestudio.jsx +15 -28
- package/src/project-browser.css +6 -0
- package/src/project-browser.jsx +9 -18
- package/src/project-start-choices.jsx +45 -0
- package/src/project.js +16 -12
- package/src/proxy-figure-shape.js +11 -0
- package/src/proxy-figure.jsx +25 -18
- package/src/scene-assets.js +17 -13
- package/src/scenes.js +22 -11
- package/src/sculpt-blueprint.js +56 -0
- package/src/sculpt-mode.js +5 -0
- package/src/sculpt-part-edit.js +115 -0
- package/src/sculpt-part-gizmo.jsx +159 -0
- package/src/sculpt-part-outline.jsx +72 -0
- package/src/sculpt-pick.js +53 -0
- package/src/sculpt-run-text.js +51 -0
- package/src/sculpt-run.js +10 -46
- package/src/sculpt-shelf-file.js +92 -0
- package/src/sculpt-shelf-tiles.jsx +69 -0
- package/src/sculpt-shelf.js +108 -0
- package/src/sculpt-stage.jsx +39 -2
- package/src/sculpt-thumb.js +72 -0
- package/src/sculpt-views.js +75 -0
- package/src/semantic-edit.js +0 -87
- package/src/settings-menu.jsx +2 -2
- package/src/shell/DetailsSlot.jsx +5 -6
- package/src/shell/LibrarySlot.jsx +8 -10
- package/src/shell/MenuBar.jsx +6 -6
- package/src/shell/PreferencesDialog.jsx +2 -3
- package/src/shell/ViewportToolbar.jsx +15 -18
- package/src/shell/add-character-entries.js +13 -0
- package/src/shell/pose-controls.js +32 -0
- package/src/shell/preference-pages.jsx +2 -11
- package/src/shell/storyboard-caption.js +21 -0
- package/src/shell/studio-shell-context.js +1 -0
- package/src/shortcut-friction.js +2 -1
- package/src/site/landing-analytics.js +28 -0
- package/src/studio-actions.js +3 -2
- package/src/studio-agent-protocol.js +4 -4
- package/src/studio-app-binding.js +4 -3
- package/src/update/UpdateChip.jsx +21 -2
- package/src/update/UpdateDialog.jsx +26 -6
- package/src/update/update-client.js +123 -17
- package/src/update/update-telemetry.js +15 -3
- package/src/use-sculpt-mode.js +62 -8
- package/src/workflow/agent-failure-codes.js +28 -9
- package/src/workflow/agent-turn-telemetry.js +1 -1
- package/src/workflow/task-board.js +2 -2
- package/tools/dev/page-shell.py +1 -1
- package/tools/dev/pages/greybox-to-video.html +21 -2
- package/tools/dev/pages/previs-software.html +3 -3
- package/tools/dev/pages/privacy.html +9 -6
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +53 -84
- package/tools/select-tests.mjs +119 -0
- package/tools/test-durations.json +455 -0
- package/tools/test-graph.mjs +181 -0
- package/tools/test-shards.mjs +78 -0
- package/tools/tutorial/README.md +74 -0
- package/tools/tutorial/assemble.mjs +43 -0
- package/tools/tutorial/captions.mjs +43 -0
- package/tools/tutorial/cdp.mjs +55 -0
- package/tools/tutorial/cursor.mjs +159 -0
- package/tools/tutorial/episode.mjs +173 -0
- package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
- package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
- package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
- package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
- package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
- package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
- package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
- package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
- package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
- package/tools/tutorial/episodes/cast.mjs +12 -0
- package/tools/tutorial/episodes/first-video.mjs +121 -0
- package/tools/tutorial/episodes/stub-tour.mjs +97 -0
- package/tools/tutorial/launch.mjs +63 -0
- package/tools/tutorial/mux.mjs +61 -0
- package/tools/tutorial/overlay.mjs +142 -0
- package/tools/tutorial/record.mjs +156 -0
- package/tools/tutorial/scenes/terminal.html +63 -0
- package/tools/tutorial/steps.mjs +111 -0
- package/tools/tutorial/terminal.mjs +43 -0
- package/tools/tutorial/voice.mjs +49 -0
- package/tools/update-test-durations.mjs +42 -0
- package/tools/verify-package-tarball.mjs +18 -12
- package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
- package/dist/assets/app-BLS1Zaru.js +0 -5144
- package/dist/assets/compile-worker-or7djI2v.js +0 -6
- package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
- package/dist/assets/task-board-CkLZGPkv.js +0 -15
- package/dist/assets/workflow-DJwOUzqJ.js +0 -179
|
@@ -137,13 +137,14 @@ export function createStudioAppBinding(ports) {
|
|
|
137
137
|
const frameRange = row => ({ startFrame: row.startFrame, endFrameExclusive: row.endFrame + 1 });
|
|
138
138
|
function context() {
|
|
139
139
|
const s = refresh(), entities = entityProjection(s), registry = ports.actions?.();
|
|
140
|
-
const shot = s.shots.find(row => row.id === s.selectedShotId) ?? shotAtFrame(s.shots, s.view.frame);
|
|
140
|
+
const shot = s.shots.find(row => row.id === s.selectedShotId) ?? shotAtFrame(s.shots, s.view.frame), cast = id => s.characters.some(c => c.id === id);
|
|
141
141
|
const range = frameRange;
|
|
142
142
|
return buildStudioContext({ schema: "studio-context-v1", host: { surface: "studio", ...s.host, workspaceHandle: s.workspaceHandle },
|
|
143
143
|
revision: { scene: s.revision, physics: s.physicsRevision, view: s.viewRevision },
|
|
144
144
|
units: { distance: "m", angle: "deg", up: "+Y", yawZero: "+Z", yawPositiveToward: "+X", pivot: "base", fps: 24, rangeEnd: "exclusive" },
|
|
145
|
-
scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length, ...(s.previsMode ? { previsMode: s.previsMode } : {}) },
|
|
146
|
-
|
|
145
|
+
scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length, ...(s.previsMode ? { previsMode: s.previsMode } : {}), ...(s.castStyle ? { castStyle: s.castStyle } : {}) },
|
|
146
|
+
// A removed character (the last one, too) is neither selected nor active.
|
|
147
|
+
selection: ["character", "rig"].includes(s.selection?.kind) && !cast(s.selection.id) ? null : s.selection, activeCharacterId: cast(s.activeCharacterId) ? s.activeCharacterId : null, view: s.view,
|
|
147
148
|
shot: shot ? { id: shot.id, name: shot.name, range: range(shot), mode: shot.camera?.mode ?? "keys" } : null, camera: s.camera,
|
|
148
149
|
// buildStudioContext selects the detailed rows and writes the real page.
|
|
149
150
|
entities, entityPage: { returned: 0, total: 0, truncated: false, nextCursor: null },
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { ko } from "../locale.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { useStudioShell } from "../shell/studio-shell-context.js";
|
|
4
|
+
import { createUpdatePoller, reloadAfterRestart } from "./update-client.js";
|
|
5
|
+
import { noteNoticeShown, noteUpdateClicked, noteUpdatePolled, noteUpdateRestarted } from "./update-telemetry.js";
|
|
5
6
|
import UpdateDialog from "./UpdateDialog.jsx";
|
|
6
7
|
import "./update.css";
|
|
7
8
|
|
|
@@ -13,6 +14,9 @@ function chipLabel(state) {
|
|
|
13
14
|
switch (state.view) {
|
|
14
15
|
case "updating": return ko("Updating…", "업데이트 중…", "正在更新…");
|
|
15
16
|
case "restart_needed": return ko("Restart to finish", "다시 시작하면 끝나요", "重启以完成");
|
|
17
|
+
case "restarting": return ko("Restarting…", "다시 시작하는 중…", "正在重启…");
|
|
18
|
+
case "restarted": return ko(`Updated to ${v}`, `${v}로 업데이트됨`, `已更新到 ${v}`);
|
|
19
|
+
case "restart_timeout": return ko("Restart didn't finish", "다시 시작하지 못했어요", "重启未完成");
|
|
16
20
|
case "failed": return ko("Update failed", "업데이트 실패", "更新失败");
|
|
17
21
|
default: return ko(`Update ${v}`, `${v} 업데이트`, `更新到 ${v}`);
|
|
18
22
|
}
|
|
@@ -22,10 +26,20 @@ export default function UpdateChip() {
|
|
|
22
26
|
const [state, setState] = useState(null);
|
|
23
27
|
const [open, setOpen] = useState(false);
|
|
24
28
|
const poller = useRef(null);
|
|
29
|
+
const { projectDirty } = useStudioShell() ?? {};
|
|
30
|
+
const dirty = useRef(projectDirty);
|
|
31
|
+
useEffect(() => { dirty.current = projectDirty; }, [projectDirty]);
|
|
25
32
|
useEffect(() => {
|
|
33
|
+
let reloaded = false;
|
|
26
34
|
const instance = createUpdatePoller({
|
|
27
35
|
onState: ({ state: next, info }) => {
|
|
28
36
|
setState(next);
|
|
37
|
+
// Decided once, when the new launcher answers: a saved project reloads, a dirty one asks first.
|
|
38
|
+
if (next.view === "restarted" && !reloaded) {
|
|
39
|
+
reloaded = true;
|
|
40
|
+
// A task later, so this tab's update:restarted is handed over first.
|
|
41
|
+
if (!reloadAfterRestart({ dirty: dirty.current, reload: () => setTimeout(() => window.location.reload(), 0) })) setOpen(true);
|
|
42
|
+
}
|
|
29
43
|
noteNoticeShown(next);
|
|
30
44
|
noteUpdatePolled(info);
|
|
31
45
|
},
|
|
@@ -45,6 +59,10 @@ export default function UpdateChip() {
|
|
|
45
59
|
if (state) noteUpdateClicked("update_now", state.kind);
|
|
46
60
|
void poller.current?.update();
|
|
47
61
|
}, [state]);
|
|
62
|
+
const restart = useCallback(() => {
|
|
63
|
+
if (state) noteUpdateClicked("restart", state.kind);
|
|
64
|
+
void poller.current?.restart().then((outcome) => { if (outcome) noteUpdateRestarted(outcome); });
|
|
65
|
+
}, [state]);
|
|
48
66
|
if (view === "hidden") return null;
|
|
49
67
|
return (
|
|
50
68
|
<span className="update-chip-wrap">
|
|
@@ -78,6 +96,7 @@ export default function UpdateChip() {
|
|
|
78
96
|
onClose={close}
|
|
79
97
|
onLater={() => dismiss("later")}
|
|
80
98
|
onUpdate={update}
|
|
99
|
+
onRestart={restart}
|
|
81
100
|
onAction={(action) => noteUpdateClicked(action, state.kind)}
|
|
82
101
|
/>
|
|
83
102
|
)}
|
|
@@ -22,6 +22,9 @@ function titleOf(state) {
|
|
|
22
22
|
if (state.view === "updating") return ko("Updating…", "업데이트 중…", "正在更新…");
|
|
23
23
|
if (state.view === "restart_needed") return ko("Restart to finish", "다시 시작하면 끝나요", "重启以完成");
|
|
24
24
|
if (state.view === "failed") return ko("Update failed", "업데이트 실패", "更新失败");
|
|
25
|
+
if (state.view === "restarting") return ko("Restarting…", "다시 시작하는 중…", "正在重启…");
|
|
26
|
+
if (state.view === "restarted") return ko(`Updated to ${v}`, `${v}로 업데이트됨`, `已更新到 ${v}`);
|
|
27
|
+
if (state.view === "restart_timeout") return ko("Restart didn't finish", "다시 시작하지 못했어요", "重启未完成");
|
|
25
28
|
return ko(`CozyClay ${v} is available`, `CozyClay ${v} 버전이 나왔어요`, `CozyClay ${v} 已发布`);
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -29,11 +32,18 @@ function bodyOf(state) {
|
|
|
29
32
|
const v = state.latest;
|
|
30
33
|
if (state.view === "updating") return ko(`Installing ${v}. This takes about a minute.`, `${v} 설치 중이에요. 1분쯤 걸려요.`, `正在安装 ${v},大约需要一分钟。`);
|
|
31
34
|
if (state.view === "restart_needed") {
|
|
32
|
-
return ko(`Updated to ${v}.
|
|
33
|
-
`${v} 설치 완료. 터미널에서 CozyClay를 종료(Ctrl+C)하고 \`cclay\`를 다시 실행하세요.`,
|
|
34
|
-
`已更新到 ${v}
|
|
35
|
+
return ko(`Updated to ${v}. Restart now, or quit CozyClay in the terminal (Ctrl+C) and run \`cclay\` again.`,
|
|
36
|
+
`${v} 설치 완료. 지금 다시 시작하거나, 터미널에서 CozyClay를 종료(Ctrl+C)하고 \`cclay\`를 다시 실행하세요.`,
|
|
37
|
+
`已更新到 ${v}。请立即重启,或在终端中退出 CozyClay(Ctrl+C)后重新运行 \`cclay\`。`);
|
|
35
38
|
}
|
|
36
39
|
if (state.view === "failed") return (failureCopy[state.errorClass] ?? failureCopy.other)();
|
|
40
|
+
if (state.view === "restarting") return ko(`Starting ${v}. The Studio reloads when it answers.`, `${v}를 시작하는 중이에요. 응답하면 Studio가 다시 로드돼요.`, `正在启动 ${v}。它响应后 Studio 会重新加载。`);
|
|
41
|
+
if (state.view === "restarted") return ko("Updated. Save your project, then reload.", "업데이트됐어요. 프로젝트를 저장한 뒤 다시 로드하세요.", "已更新。请先保存项目,然后重新加载。");
|
|
42
|
+
if (state.view === "restart_timeout") {
|
|
43
|
+
return ko("CozyClay didn't come back within 30 seconds. Quit it in the terminal (Ctrl+C), then run `cclay` again.",
|
|
44
|
+
"CozyClay가 30초 안에 돌아오지 않았어요. 터미널에서 종료(Ctrl+C)하고 `cclay`를 다시 실행하세요.",
|
|
45
|
+
"CozyClay 未在 30 秒内恢复。请在终端中退出(Ctrl+C),然后重新运行 `cclay`。");
|
|
46
|
+
}
|
|
37
47
|
if (state.kind === "npx") {
|
|
38
48
|
return ko("Quit CozyClay in the terminal and run `npx cozyclay` again. npx fetches the newest version each time (internet needed).",
|
|
39
49
|
"터미널에서 CozyClay를 종료하고 `npx cozyclay`를 다시 실행하세요. npx가 매번 최신 버전을 받아요 (인터넷 필요).",
|
|
@@ -65,7 +75,7 @@ async function copyText(text) {
|
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
|
|
68
|
-
export default function UpdateDialog({ state, onClose, onLater, onUpdate, onAction }) {
|
|
78
|
+
export default function UpdateDialog({ state, onClose, onLater, onUpdate, onRestart, onAction }) {
|
|
69
79
|
const closeRef = useRef(null);
|
|
70
80
|
const titleId = useId();
|
|
71
81
|
// "Copied" belongs to the view it was pressed in: a later phase shows its own command afresh.
|
|
@@ -89,7 +99,7 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
|
|
|
89
99
|
const view = state.view;
|
|
90
100
|
setCopiedView((await copyText(state.command)) ? view : null);
|
|
91
101
|
};
|
|
92
|
-
const startsInstall =
|
|
102
|
+
const startsInstall = ["update_now", "retry", "restart"].some((action) => state.actions.includes(action));
|
|
93
103
|
const body = bodyOf(state);
|
|
94
104
|
return createPortal(
|
|
95
105
|
<div className="menubar-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
|
@@ -127,6 +137,16 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
|
|
|
127
137
|
{ko("Try again", "다시 시도", "重试")}
|
|
128
138
|
</button>
|
|
129
139
|
)}
|
|
140
|
+
{state.actions.includes("restart") && (
|
|
141
|
+
<button type="button" className="update-dialog-primary" data-testid="update-restart" disabled={blocked} onClick={onRestart}>
|
|
142
|
+
{ko("Restart now", "지금 다시 시작", "立即重启")}
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
{state.actions.includes("reload") && (
|
|
146
|
+
<button type="button" className="update-dialog-primary" data-testid="update-reload" onClick={() => window.location.reload()}>
|
|
147
|
+
{ko("Reload", "다시 로드", "重新加载")}
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
130
150
|
{state.actions.includes("copy_command") && (
|
|
131
151
|
<button type="button" data-testid="update-copy" data-copied={copied || undefined} onClick={() => void copy()}>
|
|
132
152
|
{copied ? ko("Copied", "복사했어요", "已复制") : ko("Copy command", "명령 복사", "复制命令")}
|
|
@@ -135,7 +155,7 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
|
|
|
135
155
|
{state.actions.includes("later") && (
|
|
136
156
|
<button type="button" data-testid="update-later" onClick={onLater}>{ko("Later", "나중에", "稍后")}</button>
|
|
137
157
|
)}
|
|
138
|
-
{state.view
|
|
158
|
+
{["restart_needed", "restart_timeout"].includes(state.view) && (
|
|
139
159
|
<button type="button" onClick={onClose}>{ko("Close", "닫기", "关闭")}</button>
|
|
140
160
|
)}
|
|
141
161
|
</div>
|
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
// (bin/update-routes.mjs) read and driven from the page. Pure apart from the
|
|
3
3
|
// injected fetch, timers, storage and window, so test/verify-update-client.mjs
|
|
4
4
|
// drives it under Node. A page without window.__COZYCLAY_RUNTIME__ (the hosted
|
|
5
|
-
// site, Vite dev) makes no request at all.
|
|
5
|
+
// site, Vite dev) makes no request at all. Tabs share a dismiss, an install
|
|
6
|
+
// start and a phase change at once (U2): over a BroadcastChannel, or through a
|
|
7
|
+
// storage event where there is none. A hint only makes a tab fetch now; the
|
|
8
|
+
// launcher's answer stays the only phase a tab shows. Restart now (U2b, #960)
|
|
9
|
+
// hands the launcher to the new version and waits for it to answer.
|
|
6
10
|
export const UPDATE_URL = "/__cozyclay/update";
|
|
11
|
+
export const RESTART_URL = "/__cozyclay/update/restart";
|
|
12
|
+
export const RESTART_WAIT_MS = 30_000;
|
|
13
|
+
export const RESTART_POLL_MS = 1000;
|
|
7
14
|
export const DISMISS_KEY = "cozyclay:update-dismissed";
|
|
8
15
|
export const POLL_MS = 6 * 60 * 60 * 1000;
|
|
9
16
|
export const VISIBLE_STALE_MS = 60 * 60 * 1000;
|
|
10
17
|
export const UPDATING_POLL_MS = 2000;
|
|
18
|
+
export const CHANNEL_NAME = "cozyclay:update";
|
|
19
|
+
export const HINT_KEY = "cozyclay:update-hint";
|
|
11
20
|
|
|
12
21
|
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
13
22
|
const KINDS = new Set(["global", "npx", "clone"]);
|
|
@@ -42,16 +51,17 @@ export function normalizeUpdateInfo(body) {
|
|
|
42
51
|
phase,
|
|
43
52
|
errorClass: phase === "failed" ? (ERROR_CLASSES.has(body.errorClass) ? body.errorClass : "other") : null,
|
|
44
53
|
durationMs: Number.isFinite(body.durationMs) && body.durationMs >= 0 ? body.durationMs : null,
|
|
54
|
+
installed: version(body.installed),
|
|
45
55
|
// Opened in a new tab, so only the project's own release page is accepted.
|
|
46
56
|
notesUrl: latest && body.notesUrl === `${NOTES_PREFIX}${latest}` ? body.notesUrl : null,
|
|
47
57
|
command: kind === "npx" ? NPX_COMMAND : GLOBAL_COMMAND,
|
|
48
58
|
};
|
|
49
59
|
}
|
|
50
60
|
|
|
51
|
-
async function request(fetchImpl, init, win) {
|
|
61
|
+
async function request(fetchImpl, init, win, url = UPDATE_URL) {
|
|
52
62
|
if (!hasLauncherRuntime(win) || typeof fetchImpl !== "function") return null;
|
|
53
63
|
try {
|
|
54
|
-
const response = await fetchImpl(
|
|
64
|
+
const response = await fetchImpl(url, { cache: "no-store", credentials: "same-origin", ...init });
|
|
55
65
|
if (!response?.ok) return null;
|
|
56
66
|
return normalizeUpdateInfo(await response.json());
|
|
57
67
|
} catch {
|
|
@@ -65,6 +75,16 @@ export const fetchUpdateInfo = (fetchImpl = globalThis.fetch?.bind(globalThis),
|
|
|
65
75
|
/** POST: ask the launcher to install `latest`. The 202 body, or null (403/409/network). No body is sent. */
|
|
66
76
|
export const startUpdate = (fetchImpl = globalThis.fetch?.bind(globalThis), win = globalThis.window) => request(fetchImpl, { method: "POST" }, win);
|
|
67
77
|
|
|
78
|
+
/** POST: hand the launcher to the installed version. The 202 body, or null (403/409/network). No body is sent. */
|
|
79
|
+
export const requestRestart = (fetchImpl = globalThis.fetch?.bind(globalThis), win = globalThis.window) => request(fetchImpl, { method: "POST" }, win, RESTART_URL);
|
|
80
|
+
|
|
81
|
+
/** After the new launcher answers: reload only a saved project. True when it reloaded. */
|
|
82
|
+
export function reloadAfterRestart({ dirty, reload }) {
|
|
83
|
+
if (dirty) return false;
|
|
84
|
+
reload();
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
68
88
|
/** patch | minor | major: how far `current` is behind `latest`. */
|
|
69
89
|
export function behindOf(current, latest) {
|
|
70
90
|
const [a, b] = [current, latest].map((value) => (version(value) ?? "0.0.0").split(".").map(Number));
|
|
@@ -83,16 +103,24 @@ export function updateBlocked({ agentRunning = false, exportStatus = null, recSt
|
|
|
83
103
|
|
|
84
104
|
const HIDDEN = Object.freeze({ view: "hidden", actions: [] });
|
|
85
105
|
|
|
86
|
-
|
|
106
|
+
const RESTART_ACTIONS = { restarting: [], restarted: ["reload"], restart_timeout: ["copy_command"] };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The chip and dialog view for one poll result and the local state: { dismissed }, and { restart }
|
|
110
|
+
* (restarting | restarted | restart_timeout) once Restart now ran here or in another tab.
|
|
111
|
+
*/
|
|
87
112
|
export function nextUpdateState(info, local = {}) {
|
|
88
113
|
if (!info || !info.enabled) return HIDDEN;
|
|
89
114
|
const base = {
|
|
90
115
|
current: info.current, latest: info.latest, kind: info.kind, notesUrl: info.notesUrl,
|
|
91
116
|
command: info.command, canSelfUpdate: info.canSelfUpdate, errorClass: null,
|
|
92
117
|
};
|
|
118
|
+
if (RESTART_ACTIONS[local.restart]) return { ...base, latest: local.target ?? info.latest, view: local.restart, command: RESTART_COMMAND, actions: RESTART_ACTIONS[local.restart] };
|
|
93
119
|
const oneClick = info.kind === "global" && info.canSelfUpdate;
|
|
94
|
-
|
|
95
|
-
|
|
120
|
+
// "Installing X" and "Updated to X" name the version npm installs, which the registry may have passed since.
|
|
121
|
+
const installed = info.installed ?? info.latest;
|
|
122
|
+
if (info.phase === "updating") return { ...base, latest: installed, view: "updating", actions: [] };
|
|
123
|
+
if (info.phase === "restart_needed") return { ...base, latest: installed, view: "restart_needed", command: RESTART_COMMAND, actions: ["restart", "copy_command"] };
|
|
96
124
|
if (info.phase === "failed") {
|
|
97
125
|
return { ...base, view: "failed", errorClass: info.errorClass ?? "other", command: GLOBAL_COMMAND,
|
|
98
126
|
actions: oneClick ? ["retry", "copy_command"] : ["copy_command"] };
|
|
@@ -115,6 +143,13 @@ export function writeDismissed(latest, storage = safeStorage()) {
|
|
|
115
143
|
try { storage.setItem(DISMISS_KEY, latest); return true; } catch { return false; }
|
|
116
144
|
}
|
|
117
145
|
|
|
146
|
+
const HINTS = new Set(["dismiss", "install", "phase", "restart"]);
|
|
147
|
+
|
|
148
|
+
// A missing or throwing BroadcastChannel is null: hints then travel as a storage event.
|
|
149
|
+
function openUpdateChannel() {
|
|
150
|
+
try { return typeof globalThis.BroadcastChannel === "function" ? new globalThis.BroadcastChannel(CHANNEL_NAME) : null; } catch { return null; }
|
|
151
|
+
}
|
|
152
|
+
|
|
118
153
|
const defaultTimers = {
|
|
119
154
|
set: (fn, ms) => setTimeout(fn, ms),
|
|
120
155
|
clear: (timer) => clearTimeout(timer),
|
|
@@ -131,40 +166,97 @@ const defaultTimers = {
|
|
|
131
166
|
* The poll loop: once after the first frame and idle, every 6 h, on a visible
|
|
132
167
|
* tab whose last check is over 1 h old, every 2 s while installing. `onState`
|
|
133
168
|
* gets { state, info } after every change. Returns { dismiss, update, stop }.
|
|
169
|
+
* `channel` is a BroadcastChannel-shaped object (default: one is opened when
|
|
170
|
+
* the browser has it); null sends the other tabs' hints through `storage`.
|
|
171
|
+
* restart() and a restart hint poll every 1 s, failures included, until the
|
|
172
|
+
* launcher's `current` is the installed version, for up to 30 s.
|
|
134
173
|
*/
|
|
135
174
|
export function createUpdatePoller({
|
|
136
175
|
fetchImpl = globalThis.fetch?.bind(globalThis), onState = () => {}, timers = defaultTimers,
|
|
137
|
-
storage = safeStorage(), win = globalThis.window, doc = globalThis.document,
|
|
176
|
+
storage = safeStorage(), win = globalThis.window, doc = globalThis.document, channel: injected,
|
|
138
177
|
} = {}) {
|
|
139
|
-
const noop = { dismiss() {}, update: async () => null, stop() {} };
|
|
178
|
+
const noop = { dismiss() {}, update: async () => null, restart: async () => null, stop() {} };
|
|
140
179
|
if (!hasLauncherRuntime(win)) return noop;
|
|
141
|
-
|
|
142
|
-
let
|
|
143
|
-
|
|
180
|
+
const channel = injected === undefined ? openUpdateChannel() : injected;
|
|
181
|
+
let info = null, timer = null, stopped = false, inflight = null, lastCheck = -Infinity, hints = 0;
|
|
182
|
+
let dismissed = readDismissed(storage), restart = null, target = null, following = null;
|
|
183
|
+
const emit = () => { if (!stopped) onState({ state: nextUpdateState(info, { dismissed, restart, target }), info }); };
|
|
144
184
|
const schedule = () => {
|
|
145
185
|
if (stopped) return;
|
|
146
186
|
if (timer !== null) timers.clear(timer);
|
|
147
187
|
timer = timers.set(check, info?.phase === "updating" ? UPDATING_POLL_MS : POLL_MS);
|
|
148
188
|
};
|
|
149
|
-
|
|
150
|
-
|
|
189
|
+
// Tell the other tabs; a lost hint only costs them the wait for their next poll.
|
|
190
|
+
const hint = (type, latest = null) => {
|
|
191
|
+
try {
|
|
192
|
+
if (channel) channel.postMessage({ type, latest });
|
|
193
|
+
else if (type !== "dismiss") storage?.setItem(HINT_KEY, `${type}:${timers.now()}:${(hints += 1)}:${version(latest) ?? ""}`);
|
|
194
|
+
} catch { /* best effort */ }
|
|
195
|
+
};
|
|
196
|
+
// Emitted first, so this tab's update:finished claim is in storage before any other tab re-polls.
|
|
197
|
+
// A change a hint led to is not hinted again: the other tabs had the same hint.
|
|
198
|
+
const apply = (next, cause = "phase") => {
|
|
199
|
+
if (!next) return;
|
|
200
|
+
const changed = info !== null && info.phase !== next.phase;
|
|
201
|
+
info = next;
|
|
202
|
+
emit();
|
|
203
|
+
if (cause === "install" || (changed && cause === "phase")) hint(cause);
|
|
204
|
+
};
|
|
205
|
+
function check(cause = "phase") {
|
|
151
206
|
if (stopped) return Promise.resolve(info);
|
|
152
207
|
if (inflight) return inflight;
|
|
153
208
|
// An early check (a visible tab) replaces the pending poll; the schedule never doubles.
|
|
154
209
|
if (timer !== null) { timers.clear(timer); timer = null; }
|
|
155
210
|
lastCheck = timers.now();
|
|
156
211
|
inflight = fetchUpdateInfo(fetchImpl, win);
|
|
157
|
-
return inflight.then((next) => { inflight = null; apply(next); schedule(); return info; });
|
|
212
|
+
return inflight.then((next) => { inflight = null; apply(next, cause); schedule(); return info; });
|
|
158
213
|
}
|
|
159
214
|
const onVisible = () => {
|
|
160
215
|
if (doc?.visibilityState === "visible" && timers.now() - lastCheck > VISIBLE_STALE_MS) void check();
|
|
161
216
|
};
|
|
217
|
+
// A fetch already in flight may predate the hint, so one more follows it.
|
|
218
|
+
const refresh = () => (inflight ? inflight.then(() => check("hinted")) : check("hinted"));
|
|
219
|
+
// The old launcher stops answering for a moment, so a failed fetch is only another try.
|
|
220
|
+
const followRestart = (to) => {
|
|
221
|
+
if (following || !to) return following ?? Promise.resolve(null);
|
|
222
|
+
restart = "restarting";
|
|
223
|
+
target = to;
|
|
224
|
+
emit();
|
|
225
|
+
const deadline = timers.now() + RESTART_WAIT_MS;
|
|
226
|
+
following = (async () => {
|
|
227
|
+
for (;;) {
|
|
228
|
+
await new Promise((done) => timers.set(done, RESTART_POLL_MS));
|
|
229
|
+
if (stopped) return null;
|
|
230
|
+
const next = await fetchUpdateInfo(fetchImpl, win);
|
|
231
|
+
if (next?.current === to) { info = next; restart = "restarted"; break; }
|
|
232
|
+
if (timers.now() >= deadline) { restart = "restart_timeout"; break; }
|
|
233
|
+
}
|
|
234
|
+
emit();
|
|
235
|
+
return restart === "restarted" ? "ok" : "timeout";
|
|
236
|
+
})();
|
|
237
|
+
return following;
|
|
238
|
+
};
|
|
239
|
+
// The version the launcher installed, not the registry's latest: a newer release may appear while it waits.
|
|
240
|
+
const restartTarget = (latest) => version(latest) ?? (info?.phase === "restart_needed" ? info.installed ?? info.latest : null);
|
|
241
|
+
const onHint = (type, latest = null) => {
|
|
242
|
+
if (stopped || !HINTS.has(type)) return;
|
|
243
|
+
if (type === "restart") return void followRestart(restartTarget(latest));
|
|
244
|
+
if (type !== "dismiss") return void refresh();
|
|
245
|
+
dismissed = readDismissed(storage) ?? version(latest) ?? dismissed;
|
|
246
|
+
emit();
|
|
247
|
+
};
|
|
248
|
+
const onMessage = (event) => onHint(event?.data?.type, event?.data?.latest);
|
|
162
249
|
const onStorage = (event) => {
|
|
250
|
+
if (event?.key === HINT_KEY) { const [type, , , latest] = String(event.newValue ?? "").split(":"); return onHint(type, latest || null); }
|
|
163
251
|
if (event?.key !== DISMISS_KEY && event?.key !== null) return;
|
|
164
252
|
dismissed = readDismissed(storage);
|
|
165
253
|
emit();
|
|
166
254
|
};
|
|
167
|
-
try {
|
|
255
|
+
try {
|
|
256
|
+
doc?.addEventListener?.("visibilitychange", onVisible);
|
|
257
|
+
win?.addEventListener?.("storage", onStorage);
|
|
258
|
+
channel?.addEventListener?.("message", onMessage);
|
|
259
|
+
} catch { /* no listeners: the 6 h poll still runs */ }
|
|
168
260
|
timers.afterFirstFrame(() => { if (!stopped) void check(); });
|
|
169
261
|
return {
|
|
170
262
|
dismiss(latest) {
|
|
@@ -172,17 +264,31 @@ export function createUpdatePoller({
|
|
|
172
264
|
writeDismissed(latest, storage);
|
|
173
265
|
dismissed = latest;
|
|
174
266
|
emit();
|
|
267
|
+
hint("dismiss", latest);
|
|
175
268
|
},
|
|
176
269
|
async update() {
|
|
177
270
|
const next = await startUpdate(fetchImpl, win);
|
|
178
|
-
apply(next);
|
|
271
|
+
apply(next, "install");
|
|
179
272
|
schedule();
|
|
180
273
|
return next;
|
|
181
274
|
},
|
|
275
|
+
// Resolves to the outcome (ok | timeout), or null when the launcher refused.
|
|
276
|
+
async restart() {
|
|
277
|
+
const to = restartTarget(null);
|
|
278
|
+
if (!to || following) return following;
|
|
279
|
+
if (!(await requestRestart(fetchImpl, win))) return null;
|
|
280
|
+
hint("restart", to);
|
|
281
|
+
return followRestart(to);
|
|
282
|
+
},
|
|
182
283
|
stop() {
|
|
183
284
|
stopped = true;
|
|
184
285
|
if (timer !== null) timers.clear(timer);
|
|
185
|
-
try {
|
|
286
|
+
try {
|
|
287
|
+
doc?.removeEventListener?.("visibilitychange", onVisible);
|
|
288
|
+
win?.removeEventListener?.("storage", onStorage);
|
|
289
|
+
channel?.removeEventListener?.("message", onMessage);
|
|
290
|
+
channel?.close?.();
|
|
291
|
+
} catch { /* best effort */ }
|
|
186
292
|
},
|
|
187
293
|
};
|
|
188
294
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// update:notice_shown, update:clicked
|
|
1
|
+
// update:notice_shown, update:clicked, update:finished (the U1 update notice) and
|
|
2
|
+
// update:restarted (Restart now, #960).
|
|
2
3
|
// Only closed values leave: the install kind, how far behind (patch/minor/major),
|
|
3
4
|
// the button, the outcome, the npm error class and a duration bucket. Never a
|
|
4
5
|
// version string, a path, a command or npm's output. All three are passive
|
|
@@ -10,7 +11,8 @@ import { bucketLong } from "../analytics-strict.js";
|
|
|
10
11
|
const CLICKS_PER_SESSION = 10;
|
|
11
12
|
const FINISHED_PER_SESSION = 3;
|
|
12
13
|
const NOTICE_KINDS = new Set(["global", "npx"]);
|
|
13
|
-
const CLICK_ACTIONS = new Set(["update_now", "copy_command", "later", "dismiss", "release_notes"]);
|
|
14
|
+
const CLICK_ACTIONS = new Set(["update_now", "copy_command", "later", "dismiss", "release_notes", "restart"]);
|
|
15
|
+
const RESTART_OUTCOMES = new Set(["ok", "timeout"]);
|
|
14
16
|
// The install this browser already reported, shared by the tabs of one profile:
|
|
15
17
|
// several tabs (and several polls) see the same terminal phase, the event leaves once.
|
|
16
18
|
const REPORTED_KEY = "cozyclay:update-reported";
|
|
@@ -31,7 +33,7 @@ export function noteNoticeShown(state) {
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
/** A notice button: update_now (also Try again), copy_command, later, dismiss or
|
|
36
|
+
/** A notice button: update_now (also Try again), copy_command, later, dismiss, release_notes or restart. */
|
|
35
37
|
export function noteUpdateClicked(action, kind) {
|
|
36
38
|
try {
|
|
37
39
|
if (!CLICK_ACTIONS.has(action) || !NOTICE_KINDS.has(kind)) return;
|
|
@@ -41,6 +43,16 @@ export function noteUpdateClicked(action, kind) {
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/** Restart now ended in the tab that pressed it: the new launcher answered (ok) or 30 s passed (timeout). Once per session. */
|
|
47
|
+
export function noteUpdateRestarted(outcome) {
|
|
48
|
+
try {
|
|
49
|
+
if (!RESTART_OUTCOMES.has(outcome)) return;
|
|
50
|
+
if (admit("update:restarted", 1)) send("update:restarted", { outcome });
|
|
51
|
+
} catch {
|
|
52
|
+
// Telemetry must never affect the restart.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
function claim(key) {
|
|
45
57
|
if (reportedHere.has(key)) return false;
|
|
46
58
|
reportedHere.add(key);
|
package/src/use-sculpt-mode.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { createAgentTransport } from "./workflow/agent-client.js";
|
|
3
3
|
import { requestAgentImage, stylizeErrorMessage } from "./agent-image-client.js";
|
|
4
|
-
import { blueprintErrorMessage, blueprintRequest } from "./sculpt-blueprint.js";
|
|
5
|
-
import {
|
|
4
|
+
import { blueprintErrorMessage, blueprintRequest, blueprintViewImages } from "./sculpt-blueprint.js";
|
|
5
|
+
import { browserSculptImages } from "./commands/sculpt-images.js";
|
|
6
|
+
import { agentSessionReady, chatgptSignedIn, createCommandTap, sculptModelChoice, sculptTargetFromSelection, storeSculptModel } from "./sculpt-mode.js";
|
|
6
7
|
import { SCULPT_KIND } from "./scene-objects.js";
|
|
8
|
+
import { overlayOwnsEscape, sculptPartTree } from "./sculpt-pick.js";
|
|
9
|
+
import { GIZMO_HOTKEYS } from "./stage-startup-defaults.js";
|
|
10
|
+
import { ko } from "./locale.js";
|
|
7
11
|
|
|
8
|
-
export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId }) {
|
|
12
|
+
export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId, appContext }) {
|
|
9
13
|
// Sculpt mode (#739): one sculpt on an isolated turntable, the Sculpt panel
|
|
10
14
|
// and its agent run. The target is the sculpt selected on entry, or a new one
|
|
11
15
|
// the first run creates; picking another sculpt while here retargets.
|
|
@@ -17,6 +21,8 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
|
|
|
17
21
|
const [sculptRun, setSculptRun] = useState(null);
|
|
18
22
|
const sculptRunRef = useRef(null);
|
|
19
23
|
const [sculptAgentStatus, setSculptAgentStatus] = useState("unknown");
|
|
24
|
+
// Whether the sidecar holds a ChatGPT token: the blueprint's image model needs one (#970).
|
|
25
|
+
const [sculptChatgpt, setSculptChatgpt] = useState(false);
|
|
20
26
|
// The model Sculpt runs on (its own pick, remembered apart from the chat model) and the list it comes from.
|
|
21
27
|
const [sculptModels, setSculptModels] = useState({ state: "loading", providers: [], models: [] });
|
|
22
28
|
const [sculptModelPick, setSculptModelPick] = useState({ model: "", effort: null });
|
|
@@ -47,8 +53,8 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
|
|
|
47
53
|
let live = true;
|
|
48
54
|
const read = () => {
|
|
49
55
|
sculptTransport.status().then(
|
|
50
|
-
(status) => { if (live) setSculptAgentStatus(agentSessionReady(status) ? "ready" : "signed-out"); },
|
|
51
|
-
() => { if (live) setSculptAgentStatus("signed-out"); },
|
|
56
|
+
(status) => { if (live) { setSculptAgentStatus(agentSessionReady(status) ? "ready" : "signed-out"); setSculptChatgpt(chatgptSignedIn(status)); } },
|
|
57
|
+
() => { if (live) { setSculptAgentStatus("signed-out"); setSculptChatgpt(false); } },
|
|
52
58
|
);
|
|
53
59
|
// The Agent panel's catalogue: a key added or removed elsewhere changes what can run.
|
|
54
60
|
sculptTransport.models().then(
|
|
@@ -65,6 +71,50 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
|
|
|
65
71
|
window.addEventListener("focus", read);
|
|
66
72
|
return () => { live = false; window.removeEventListener("focus", read); };
|
|
67
73
|
}, [sculptMode, sculptTransport]);
|
|
74
|
+
// Part picking (#740 S7): one part of the target, chosen on the turntable or
|
|
75
|
+
// in the panel's tree. A new target or leaving the mode drops it; Esc drops
|
|
76
|
+
// it before Esc reaches the scene selection, unless an open menu or dialog takes that Esc.
|
|
77
|
+
const [sculptPart, setSculptPart] = useState(null);
|
|
78
|
+
useEffect(() => { setSculptPart(null); }, [sculptMode, sculptTargetId]);
|
|
79
|
+
// The part gizmo (#740 S8): its tool, and the last refusal or notice it shows under the tree.
|
|
80
|
+
const [partTool, setPartTool] = useState("move");
|
|
81
|
+
const [partNotice, setPartNotice] = useState(null);
|
|
82
|
+
useEffect(() => { setPartNotice(null); }, [sculptPart?.partId, sculptPart?.mirrored, sculptTargetId]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!sculptMode || !sculptPart) return undefined;
|
|
85
|
+
const onKey = (event) => {
|
|
86
|
+
if (event.target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
|
87
|
+
// Delete with a part picked would take the whole sculpt: refused, Esc first.
|
|
88
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
89
|
+
event.stopPropagation();
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
setPartNotice(ko("Delete removes the whole sculpt. Press Esc to deselect the part first.", "Delete는 조형물 전체를 지워요. 먼저 Esc로 부품 선택을 해제하세요.", "Delete 会删除整个雕塑。请先按 Esc 取消部件选择。"));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (event.key !== "Escape" || overlayOwnsEscape()) return;
|
|
95
|
+
event.stopPropagation();
|
|
96
|
+
setSculptPart(null);
|
|
97
|
+
};
|
|
98
|
+
window.addEventListener("keydown", onKey, true);
|
|
99
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
100
|
+
}, [sculptMode, sculptPart]);
|
|
101
|
+
const sculptRecipe = sculptTarget?.recipe;
|
|
102
|
+
const sculptTree = useMemo(() => sculptPartTree(sculptRecipe), [sculptRecipe]);
|
|
103
|
+
// A recipe edit that removed the part leaves nothing selected.
|
|
104
|
+
const selectedPart = sculptPart && sculptTree.some((row) => row.id === sculptPart.partId) ? sculptPart : null;
|
|
105
|
+
// W/E/R: the selected part's move, rotate and scale; with no part, the turntable views.
|
|
106
|
+
const onKey = (code) => (selectedPart ? setPartTool(GIZMO_HOTKEYS[code]) : requestSculptView({ KeyW: "front", KeyE: "side", KeyR: "back" }[code]));
|
|
107
|
+
const tools = [["W", "move", ko("Move part", "부품 이동", "移动部件")], ["E", "rotate", ko("Rotate part", "부품 회전", "旋转部件")], ["R", "scale", ko("Scale part (Shift: uniform)", "부품 크기 (Shift: 균등)", "缩放部件(Shift:等比)")]]
|
|
108
|
+
.map(([key, id, name]) => ({ key, id, name, active: partTool === id, pick: () => setPartTool(id) }));
|
|
109
|
+
// One drag, one write: the command refuses what cannot be drawn and its reason is shown.
|
|
110
|
+
const commit = (edit) => {
|
|
111
|
+
if (!edit || !sculptTarget || !appContext) return false;
|
|
112
|
+
let receipt;
|
|
113
|
+
try { receipt = appContext.bus.run("object.sculpt", { id: sculptTarget.id, edits: [edit] }, { origin: "ui" }); } catch (error) { receipt = { ok: false, message: error?.message }; }
|
|
114
|
+
setPartNotice(receipt?.ok === false ? `${ko("Edit refused", "편집 거부", "编辑被拒绝")}: ${receipt.message ?? ""}` : null);
|
|
115
|
+
return receipt?.ok !== false;
|
|
116
|
+
};
|
|
117
|
+
const sculptParts = { tree: sculptTree, selected: selectedPart, onSelect: setSculptPart, tool: partTool, tools, onKey, commit, notice: partNotice };
|
|
68
118
|
// The blueprint step: the reference turned into a four-view model sheet by one image request.
|
|
69
119
|
const [sculptBlueprint, setSculptBlueprint] = useState({ state: "idle", pose: "keep", notes: "", sheet: null, error: null, startedAt: null });
|
|
70
120
|
const blueprintAbortRef = useRef(null);
|
|
@@ -78,7 +128,11 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
|
|
|
78
128
|
try {
|
|
79
129
|
const result = await requestAgentImage(blueprintRequest({ reference: sculptReference, pose: sculptBlueprint.pose, notes: sculptBlueprint.notes }), { signal: controller.signal });
|
|
80
130
|
if (blueprintAbortRef.current !== controller) return;
|
|
81
|
-
|
|
131
|
+
// Cut into views (#970): side and back go to Start next to the reference; a view whose mask fails is dropped with a notice.
|
|
132
|
+
let cropped;
|
|
133
|
+
try { cropped = await blueprintViewImages(result.dataUrl, browserSculptImages); } catch (error) { cropped = { views: [], notices: [`The sheet could not be cut into views: ${error?.message || error}`] }; }
|
|
134
|
+
if (blueprintAbortRef.current !== controller) return;
|
|
135
|
+
setSculptBlueprint((current) => ({ ...current, state: "ready", sheet: { dataUrl: result.dataUrl, width: result.width, height: result.height, ms: Date.now() - startedAt, views: cropped.views, notices: cropped.notices } }));
|
|
82
136
|
} catch (error) {
|
|
83
137
|
if (blueprintAbortRef.current !== controller) return;
|
|
84
138
|
const cancelled = controller.signal.aborted;
|
|
@@ -91,5 +145,5 @@ export function useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectI
|
|
|
91
145
|
controller?.abort();
|
|
92
146
|
setSculptBlueprint((current) => ({ ...current, state: current.sheet ? "ready" : "idle" }));
|
|
93
147
|
}
|
|
94
|
-
return { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint };
|
|
148
|
+
return { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptChatgpt, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint, sculptParts };
|
|
95
149
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The closed `agent:turn_failed.failure_code` vocabulary, browser side. The
|
|
2
2
|
// sidecar keeps an identical table in bin/agent/agent-failure-codes.mjs (it
|
|
3
3
|
// cannot import src/); test/verify-agent-execution.mjs holds both to the same
|
|
4
|
-
// answers. Only the structured code and HTTP status are read,
|
|
4
|
+
// answers. Only the error's name, structured code and HTTP status are read,
|
|
5
|
+
// never a message.
|
|
5
6
|
//
|
|
6
7
|
// key_refused the model provider rejected the credentials (provider
|
|
7
8
|
// 401/403, structured or message-only: runner `unauthorized`)
|
|
@@ -14,9 +15,15 @@
|
|
|
14
15
|
// LIVE_HUB_UNAVAILABLE: no connected editor)
|
|
15
16
|
// busy a turn is already running, or the target is busy
|
|
16
17
|
// invalid_request the sidecar rejected the request (400, INVALID_*, other 4xx)
|
|
17
|
-
// upstream provider
|
|
18
|
-
//
|
|
19
|
-
|
|
18
|
+
// upstream provider trouble: HTTP 5xx, or the runner's `overloaded`,
|
|
19
|
+
// `upstream`, `truncated` (and the provider's `server_error`)
|
|
20
|
+
// network the request never got an HTTP answer: a fetch TypeError
|
|
21
|
+
// (offline, refused, DNS) or a socket code (ECONNRESET,
|
|
22
|
+
// ENOTFOUND, ...), on the error or its `cause`
|
|
23
|
+
// unknown anything else, including an error whose fields throw
|
|
24
|
+
// aborted / tool_failed are assigned by the turn, not by this table
|
|
25
|
+
// Rows before the network/unknown split carry `upstream` for all three.
|
|
26
|
+
export const AGENT_FAILURE_CODES = new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "network", "unknown",
|
|
20
27
|
"key_refused", "stale_scene", "capability_missing", "busy", "invalid_request"]);
|
|
21
28
|
|
|
22
29
|
const BY_CODE = {
|
|
@@ -24,24 +31,36 @@ const BY_CODE = {
|
|
|
24
31
|
STALE_SCENE: "stale_scene", STALE_TARGET: "stale_scene", STALE_ENVIRONMENT: "stale_scene",
|
|
25
32
|
CAPABILITY_MISSING: "capability_missing", LIVE_HUB_UNAVAILABLE: "capability_missing",
|
|
26
33
|
busy: "busy", TARGET_BUSY: "busy", SCOPE_BUSY: "busy", TARGET_NOT_READY: "busy",
|
|
34
|
+
// A sidecar frame whose code is already this table's fallback answer passes through.
|
|
35
|
+
network: "network", unknown: "unknown",
|
|
27
36
|
};
|
|
37
|
+
// The runner's provider codes read as upstream, after the status: a 4xx beside one is the request's fault.
|
|
38
|
+
const PROVIDER_CODES = new Set(["overloaded", "upstream", "truncated", "server_error"]);
|
|
39
|
+
const NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ECONNABORTED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT", "EPIPE",
|
|
40
|
+
"ENETUNREACH", "ENETDOWN", "EHOSTUNREACH", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
41
|
+
const codeOf = (value) => (typeof value?.code === "string" ? value.code : "");
|
|
28
42
|
|
|
29
|
-
/** `{ code, status }` -> failure code. Foreign getters
|
|
43
|
+
/** `{ name, code, status }` -> failure code. Foreign getters read as unknown. */
|
|
30
44
|
export function transportFailureCode(error) {
|
|
31
45
|
try {
|
|
32
|
-
const code =
|
|
46
|
+
const code = codeOf(error);
|
|
33
47
|
const status = error?.status;
|
|
34
48
|
if (Object.hasOwn(BY_CODE, code)) return BY_CODE[code];
|
|
35
49
|
if (code.startsWith("INVALID_")) return "invalid_request";
|
|
36
50
|
if (status === 401 || status === 403) return "auth";
|
|
37
51
|
if (status === 429) return "rate_limited";
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
if (Number.isInteger(status) && status >= 400 && status < 500) return "invalid_request";
|
|
53
|
+
if (Number.isInteger(status) && status >= 500 && status < 600) return "upstream";
|
|
54
|
+
if (PROVIDER_CODES.has(code)) return "upstream";
|
|
55
|
+
if (Number.isInteger(status)) return "unknown";
|
|
56
|
+
if (NETWORK_CODES.has(code) || NETWORK_CODES.has(codeOf(error?.cause)) || error?.name === "TypeError") return "network";
|
|
57
|
+
return "unknown";
|
|
58
|
+
} catch { return "unknown"; }
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
/** A refused turn: the sidecar's `{ error: { code } }` body plus the HTTP status. */
|
|
43
62
|
export async function refusalFailureCode(response) {
|
|
44
63
|
let code;
|
|
45
64
|
try { const body = await response.clone().json(); code = typeof body?.error?.code === "string" ? body.error.code : undefined; } catch { /* status alone */ }
|
|
46
|
-
try { return transportFailureCode({ code, status: response?.status }); } catch { return "
|
|
65
|
+
try { return transportFailureCode({ code, status: response?.status }); } catch { return "unknown"; }
|
|
47
66
|
}
|
|
@@ -142,7 +142,7 @@ export function startAgentTurn({ surface, capture, now, correlationId = null })
|
|
|
142
142
|
return {
|
|
143
143
|
turnId,
|
|
144
144
|
cancel: () => finish("cancelled", "aborted"),
|
|
145
|
-
fail: (code) => finish("failed", AGENT_FAILURE_CODES.has(code) ? code : "
|
|
145
|
+
fail: (code) => finish("failed", AGENT_FAILURE_CODES.has(code) ? code : "unknown"),
|
|
146
146
|
/** A tool.start / tool.done frame the panel sees (after replay dedupe). */
|
|
147
147
|
tool(event) {
|
|
148
148
|
if (!terminal) advisory(() => tally.note(event), null);
|