duet-mcp 0.6.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/LICENSE +21 -0
- package/README.md +367 -0
- package/doc/README.jp.md +347 -0
- package/lib/blob.d.ts +16 -0
- package/lib/blob.js +57 -0
- package/lib/boot.d.ts +2 -0
- package/lib/boot.js +134 -0
- package/lib/client-store.d.ts +28 -0
- package/lib/client-store.js +147 -0
- package/lib/client.d.ts +25 -0
- package/lib/client.js +58 -0
- package/lib/diff.d.ts +27 -0
- package/lib/diff.js +103 -0
- package/lib/doc.d.ts +30 -0
- package/lib/doc.js +221 -0
- package/lib/edit.d.ts +25 -0
- package/lib/edit.js +63 -0
- package/lib/http.d.ts +9 -0
- package/lib/http.js +151 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +2 -0
- package/lib/mcp.d.ts +5 -0
- package/lib/mcp.js +109 -0
- package/lib/op.d.ts +10 -0
- package/lib/op.js +19 -0
- package/lib/paths.d.ts +4 -0
- package/lib/paths.js +9 -0
- package/lib/protocol.d.ts +36 -0
- package/lib/protocol.js +10 -0
- package/lib/server.d.ts +1 -0
- package/lib/server.js +1 -0
- package/lib/shot.d.ts +8 -0
- package/lib/shot.js +85 -0
- package/lib/transport.d.ts +7 -0
- package/lib/transport.js +22 -0
- package/lib/types.d.ts +66 -0
- package/lib/types.js +1 -0
- package/lib/wire.d.ts +14 -0
- package/lib/wire.js +42 -0
- package/package.json +97 -0
- package/template/app.ts +17 -0
- package/template/doc.ts +18 -0
- package/template/main.ts +8 -0
- package/template/ops.ts +42 -0
- package/template/start.ts +4 -0
- package/template/ui/canvas.tsx +91 -0
- package/template/ui/card-editing.tsx +48 -0
- package/template/ui/edit-actions.tsx +30 -0
- package/template/ui/index.html +15 -0
- package/template/ui/main.tsx +53 -0
- package/template/ui/style.css +15 -0
- package/template/ui/tsconfig.json +15 -0
- package/template/ui/vite.config.ts +24 -0
package/lib/wire.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* サーバ・ブラウザ・vite の設定が共有する定数。
|
|
3
|
+
* どこから読まれてもよいように、node の API に依存させないこと。
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* ポートは app.id から導出する(FNV-1a)。
|
|
7
|
+
* 設定項目を持たないので、分け忘れによる別アプリへの誤接続が起きない。
|
|
8
|
+
*/
|
|
9
|
+
export declare function portFor(appId: string): number;
|
|
10
|
+
export declare const baseUrlFor: (appId: string) => string;
|
|
11
|
+
/** ロングポーリングの長さ。サーバもブラウザも vite もここだけを読む。 */
|
|
12
|
+
export declare const WAIT_MS = 25000;
|
|
13
|
+
export declare const MAX_WAIT_MS = 120000;
|
|
14
|
+
export declare const MIN_WAIT_MS = 1000;
|
package/lib/wire.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* サーバ・ブラウザ・vite の設定が共有する定数。
|
|
3
|
+
* どこから読まれてもよいように、node の API に依存させないこと。
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* 逃げ道。導出したポートが他のプロセスと衝突したときだけ使う。
|
|
7
|
+
*
|
|
8
|
+
* 既定を設定項目にはしない(分け忘れによる別アプリへの誤接続を防ぐため)。
|
|
9
|
+
* ただし 8000-8999 の 1000 枠なので、衝突したときにアプリ名を変えるしか
|
|
10
|
+
* 手が無いのは行き過ぎだった。
|
|
11
|
+
*
|
|
12
|
+
* 注意: daemon と vite の両方のプロセスに同じ値を渡すこと。片方だけだと
|
|
13
|
+
* dev server のプロキシ先が daemon と食い違う。
|
|
14
|
+
*/
|
|
15
|
+
function override() {
|
|
16
|
+
// ここはブラウザにもバンドルされる。process の存在を確かめてから読む。
|
|
17
|
+
const raw = typeof process === "undefined" ? undefined : process.env?.DUET_PORT;
|
|
18
|
+
if (!raw)
|
|
19
|
+
return undefined;
|
|
20
|
+
const n = Number(raw);
|
|
21
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* ポートは app.id から導出する(FNV-1a)。
|
|
25
|
+
* 設定項目を持たないので、分け忘れによる別アプリへの誤接続が起きない。
|
|
26
|
+
*/
|
|
27
|
+
export function portFor(appId) {
|
|
28
|
+
const forced = override();
|
|
29
|
+
if (forced !== undefined)
|
|
30
|
+
return forced;
|
|
31
|
+
let h = 2166136261;
|
|
32
|
+
for (let i = 0; i < appId.length; i += 1) {
|
|
33
|
+
h ^= appId.charCodeAt(i);
|
|
34
|
+
h = Math.imul(h, 16777619);
|
|
35
|
+
}
|
|
36
|
+
return 8000 + ((h >>> 0) % 1000);
|
|
37
|
+
}
|
|
38
|
+
export const baseUrlFor = (appId) => `http://127.0.0.1:${portFor(appId)}`;
|
|
39
|
+
/** ロングポーリングの長さ。サーバもブラウザも vite もここだけを読む。 */
|
|
40
|
+
export const WAIT_MS = 25_000;
|
|
41
|
+
export const MAX_WAIT_MS = 120_000;
|
|
42
|
+
export const MIN_WAIT_MS = 1_000;
|
package/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "duet-mcp",
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "npm run build:package && npm run build:server && npm run build:web",
|
|
7
|
+
"build:server": "tsc -p tsconfig.json",
|
|
8
|
+
"build:web": "for c in */ui/vite.config.ts; do vite build --config \"$c\" || exit 1; done",
|
|
9
|
+
"dev:web": "vite --config ${DUET_APP:-template}/ui/vite.config.ts",
|
|
10
|
+
"start": "node dist/${DUET_APP:-template}/main.js",
|
|
11
|
+
"typecheck": "npm run build:package && tsc -p tsconfig.json --noEmit && for c in */ui/tsconfig.json; do tsc -p \"$c\" || exit 1; done",
|
|
12
|
+
"test": "npm run build && node --test dist/tests/*.test.js",
|
|
13
|
+
"build:package": "tsc -p tsconfig.package.json",
|
|
14
|
+
"prepack": "npm run build:package",
|
|
15
|
+
"test:package": "node scripts/test-package.mjs"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@hono/node-server": "^1.13.7",
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
20
|
+
"hono": "^4.6.14",
|
|
21
|
+
"playwright": "^1.49.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
25
|
+
"@types/node": "^22.10.2",
|
|
26
|
+
"@types/react": "^18.3.17",
|
|
27
|
+
"@types/react-dom": "^18.3.5",
|
|
28
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
29
|
+
"tailwindcss": "^4.3.3",
|
|
30
|
+
"typescript": "^5.7.2",
|
|
31
|
+
"vite": "^6.0.5",
|
|
32
|
+
"react": "^18.3.1",
|
|
33
|
+
"react-dom": "^18.3.1",
|
|
34
|
+
"zod": "^3.23.8"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"description": "A shared JSON document for humans through a GUI and LLMs through MCP.",
|
|
38
|
+
"author": "Taniguchi Ryoga (SabaCan0141)",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/SabaCan0141/duet-mcp.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/SabaCan0141/duet-mcp#readme",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/SabaCan0141/duet-mcp/issues"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"lib",
|
|
52
|
+
"template/*.ts",
|
|
53
|
+
"template/ui/*.ts",
|
|
54
|
+
"template/ui/*.tsx",
|
|
55
|
+
"template/ui/*.json",
|
|
56
|
+
"template/ui/*.html",
|
|
57
|
+
"template/ui/*.css",
|
|
58
|
+
"doc/README.jp.md"
|
|
59
|
+
],
|
|
60
|
+
"types": "./lib/index.d.ts",
|
|
61
|
+
"exports": {
|
|
62
|
+
".": {
|
|
63
|
+
"types": "./lib/index.d.ts",
|
|
64
|
+
"import": "./lib/index.js"
|
|
65
|
+
},
|
|
66
|
+
"./server": {
|
|
67
|
+
"types": "./lib/server.d.ts",
|
|
68
|
+
"import": "./lib/server.js"
|
|
69
|
+
},
|
|
70
|
+
"./react": {
|
|
71
|
+
"types": "./lib/client.d.ts",
|
|
72
|
+
"import": "./lib/client.js"
|
|
73
|
+
},
|
|
74
|
+
"./wire": {
|
|
75
|
+
"types": "./lib/wire.d.ts",
|
|
76
|
+
"import": "./lib/wire.js"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"publishConfig": {
|
|
80
|
+
"access": "public"
|
|
81
|
+
},
|
|
82
|
+
"keywords": [
|
|
83
|
+
"mcp",
|
|
84
|
+
"gui",
|
|
85
|
+
"human-in-the-loop",
|
|
86
|
+
"react"
|
|
87
|
+
],
|
|
88
|
+
"peerDependencies": {
|
|
89
|
+
"react": "^18.3.1",
|
|
90
|
+
"zod": "^3.23.8"
|
|
91
|
+
},
|
|
92
|
+
"peerDependenciesMeta": {
|
|
93
|
+
"react": {
|
|
94
|
+
"optional": true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/template/app.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { defineApp } from "duet-mcp";
|
|
3
|
+
import { initialDoc, type Doc } from "./doc.js";
|
|
4
|
+
import { ops } from "./ops.js";
|
|
5
|
+
|
|
6
|
+
export const app = defineApp<Doc>({
|
|
7
|
+
id: "template",
|
|
8
|
+
// Resolve the app root from dist/template/app.js, independently of the MCP client cwd.
|
|
9
|
+
rootDir: fileURLToPath(new URL("../../", import.meta.url)),
|
|
10
|
+
version: "0.1.0",
|
|
11
|
+
initialDoc,
|
|
12
|
+
ops,
|
|
13
|
+
// The port is derived from the app id. A GUI is required.
|
|
14
|
+
webDist: "template/ui/dist",
|
|
15
|
+
// Capture the same GUI for the LLM; only the capture region needs configuration.
|
|
16
|
+
shot: { selector: "#studio", viewport: { w: 1440, h: 1150 } },
|
|
17
|
+
});
|
package/template/doc.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// JSON shared by humans and LLMs. In-progress drafts stay in the UI.
|
|
2
|
+
export type Box = { x: number; y: number; width: number; height: number };
|
|
3
|
+
export type Settings = {
|
|
4
|
+
caption: string;
|
|
5
|
+
notes: string;
|
|
6
|
+
visible: boolean;
|
|
7
|
+
grid: boolean;
|
|
8
|
+
style: "solid" | "outline";
|
|
9
|
+
color: "violet" | "blue" | "coral";
|
|
10
|
+
opacity: number;
|
|
11
|
+
};
|
|
12
|
+
export type Doc = { text: string; settings: Settings; box: Box };
|
|
13
|
+
export const defaultSettings = (): Settings => ({
|
|
14
|
+
caption: "Make room for ideas.", notes: "One idea, shaped together by you and AI.",
|
|
15
|
+
visible: true, grid: true, style: "solid", color: "violet", opacity: 100,
|
|
16
|
+
});
|
|
17
|
+
export const initialDoc = (): Doc => ({ text: "", settings: defaultSettings(), box: { x: 160, y: 120, width: 360, height: 220 } });
|
|
18
|
+
export const CANVAS = { width: 720, height: 480, min: 64 };
|
package/template/main.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Static ESM imports run before console redirection.
|
|
2
|
+
// Any extra stdout output can break JSON-RPC. Keep this entry point limited to
|
|
3
|
+
// redirecting console output and dynamically importing the application.
|
|
4
|
+
console.log = console.error;
|
|
5
|
+
console.info = console.error;
|
|
6
|
+
console.debug = console.error;
|
|
7
|
+
|
|
8
|
+
await import("./start.js");
|
package/template/ops.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { opFactory } from "duet-mcp";
|
|
3
|
+
import type { Op } from "duet-mcp";
|
|
4
|
+
import type { Doc } from "./doc.js";
|
|
5
|
+
|
|
6
|
+
const op = opFactory<Doc>();
|
|
7
|
+
|
|
8
|
+
// Each operation becomes both an MCP tool and a POST /api/op/:name endpoint.
|
|
9
|
+
// Define operations around intent. Keep typing and drag previews local until committed.
|
|
10
|
+
export const ops: Op<Doc>[] = [
|
|
11
|
+
op({
|
|
12
|
+
name: "set_text",
|
|
13
|
+
description: "Replace the shared note text.",
|
|
14
|
+
input: { text: z.string() },
|
|
15
|
+
handler: ({ doc }, { text }) => {
|
|
16
|
+
// The revision stays unchanged for a no-op; no comparison is needed here.
|
|
17
|
+
doc.text = text;
|
|
18
|
+
return { text };
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Commit the form as one edit.
|
|
24
|
+
ops.push(op({
|
|
25
|
+
name: "set_settings",
|
|
26
|
+
description: "Set the canvas heading, notes, visibility, grid, style, color, and opacity.",
|
|
27
|
+
input: {
|
|
28
|
+
caption: z.string().max(80), notes: z.string().max(500), visible: z.boolean(), grid: z.boolean(),
|
|
29
|
+
style: z.enum(["solid", "outline"]), color: z.enum(["violet", "blue", "coral"]),
|
|
30
|
+
opacity: z.number().int().min(10).max(100),
|
|
31
|
+
},
|
|
32
|
+
handler: ({ doc }, settings) => { doc.settings = settings; },
|
|
33
|
+
}));
|
|
34
|
+
ops.push(op({
|
|
35
|
+
name: "set_box",
|
|
36
|
+
description: "Move or resize the box within a 720×480 canvas. The minimum size is 64×64.",
|
|
37
|
+
input: { x: z.number().min(0), y: z.number().min(0), width: z.number().min(64), height: z.number().min(64) },
|
|
38
|
+
handler: ({ doc, reject }, box) => {
|
|
39
|
+
if (box.x + box.width > 720 || box.y + box.height > 480) return reject("Keep the box within the canvas bounds.");
|
|
40
|
+
doc.box = box;
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useEdit, type Observed } from "duet-mcp/react";
|
|
3
|
+
import { CANVAS, type Box, type Doc, type Settings } from "../doc";
|
|
4
|
+
import { EditActions } from "./edit-actions";
|
|
5
|
+
const colors = { violet: "#7c3aed", blue: "#2563eb", coral: "#e76c55" };
|
|
6
|
+
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
|
7
|
+
type Gesture = { id: number; x: number; y: number; box: Box; mode: string; latest: Box };
|
|
8
|
+
|
|
9
|
+
export function Canvas({ snap, settings, box: saved }: { snap: Observed<Doc>; settings: Settings; box: Box }) {
|
|
10
|
+
const edit = useEdit<Box>();
|
|
11
|
+
const ref = useRef<HTMLCanvasElement>(null);
|
|
12
|
+
const gesture = useRef<Gesture | null>(null);
|
|
13
|
+
const [displayWidth, setDisplayWidth] = useState(CANVAS.width);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const observer = new ResizeObserver(([entry]) => setDisplayWidth(entry.contentRect.width));
|
|
16
|
+
observer.observe(ref.current!);
|
|
17
|
+
return () => observer.disconnect();
|
|
18
|
+
}, []);
|
|
19
|
+
const box = edit.active ? edit.value! : saved;
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const canvas = ref.current!;
|
|
22
|
+
const dpr = window.devicePixelRatio || 1;
|
|
23
|
+
canvas.width = Math.max(1, Math.round(displayWidth * dpr)); canvas.height = Math.max(1, Math.round(displayWidth * 2 / 3 * dpr));
|
|
24
|
+
const c = canvas.getContext("2d")!;
|
|
25
|
+
c.scale(canvas.width / CANVAS.width, canvas.height / CANVAS.height);
|
|
26
|
+
c.fillStyle = "#fcfcfe"; c.fillRect(0, 0, 720, 480);
|
|
27
|
+
if (settings.grid) {
|
|
28
|
+
c.fillStyle = "#dddde8";
|
|
29
|
+
for (let x = 24; x < 720; x += 24) for (let y = 24; y < 480; y += 24) { c.beginPath(); c.arc(x, y, 1, 0, Math.PI * 2); c.fill(); }
|
|
30
|
+
}
|
|
31
|
+
if (!settings.visible) return;
|
|
32
|
+
c.save(); c.globalAlpha = settings.opacity / 100;
|
|
33
|
+
c.fillStyle = settings.style === "solid" ? colors[settings.color] : "#fff";
|
|
34
|
+
c.strokeStyle = colors[settings.color]; c.lineWidth = 2;
|
|
35
|
+
c.beginPath(); c.roundRect(box.x, box.y, box.width, box.height, 12); c.fill(); c.stroke();
|
|
36
|
+
c.save(); c.beginPath(); c.rect(box.x + 12, box.y + 10, Math.max(0, box.width - 24), Math.max(0, box.height - 20)); c.clip();
|
|
37
|
+
c.fillStyle = settings.style === "solid" ? "#fff" : colors[settings.color];
|
|
38
|
+
c.font = "600 24px system-ui"; c.textAlign = "center";
|
|
39
|
+
c.fillText(settings.caption, box.x + box.width / 2, box.y + box.height / 2, Math.max(1, box.width - 36));
|
|
40
|
+
c.font = "11px system-ui"; c.globalAlpha *= .7;
|
|
41
|
+
c.fillText("A LITTLE SPACE FOR SOMETHING GREAT", box.x + box.width / 2, box.y + box.height / 2 + 28, Math.max(1, box.width - 36));
|
|
42
|
+
c.restore(); c.restore();
|
|
43
|
+
c.strokeStyle = "#8b5cf6"; c.lineWidth = 1; c.strokeRect(box.x - 5, box.y - 5, box.width + 10, box.height + 10);
|
|
44
|
+
for (const [x, y] of [[box.x,box.y],[box.x+box.width,box.y],[box.x,box.y+box.height],[box.x+box.width,box.y+box.height]]) {
|
|
45
|
+
c.fillStyle = "white"; c.fillRect(x-5,y-5,10,10); c.strokeRect(x-5,y-5,10,10);
|
|
46
|
+
}
|
|
47
|
+
}, [box, settings, displayWidth]);
|
|
48
|
+
const point = (e: React.PointerEvent<HTMLCanvasElement>) => { const r=e.currentTarget.getBoundingClientRect(); return { x:(e.clientX-r.left)*720/r.width, y:(e.clientY-r.top)*480/r.height }; };
|
|
49
|
+
const hit = (p: {x:number;y:number}) => {
|
|
50
|
+
const corners = [["nw",box.x,box.y],["ne",box.x+box.width,box.y],["sw",box.x,box.y+box.height],["se",box.x+box.width,box.y+box.height]] as const;
|
|
51
|
+
const radius = 12 * 720 / (ref.current?.getBoundingClientRect().width || 720);
|
|
52
|
+
for (const [mode,x,y] of corners) if (Math.abs(p.x-x)<radius && Math.abs(p.y-y)<radius) return mode;
|
|
53
|
+
return p.x>=box.x && p.x<=box.x+box.width && p.y>=box.y && p.y<=box.y+box.height ? "move" : "";
|
|
54
|
+
};
|
|
55
|
+
const cancel = () => { if (gesture.current) { gesture.current=null; edit.cancel(); } };
|
|
56
|
+
return <section className="panel overflow-hidden">
|
|
57
|
+
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4"><h2 className="text-sm font-semibold">Canvas playground</h2><span className="rounded-md bg-slate-100 px-2 py-1 text-[10px] text-slate-500">720 × 480</span></div>
|
|
58
|
+
<div className="p-4 sm:p-6">
|
|
59
|
+
<canvas ref={ref} aria-label="Canvas: move and resize the box" tabIndex={0} className="block aspect-[3/2] w-full touch-none rounded-xl border border-slate-200"
|
|
60
|
+
onKeyDown={e => { if(e.key === "Escape") cancel(); }}
|
|
61
|
+
onPointerDown={e => {
|
|
62
|
+
if(e.button!==0 || !settings.visible || edit.active || gesture.current) return;
|
|
63
|
+
const p=point(e), mode=hit(p); if(!mode) return;
|
|
64
|
+
e.currentTarget.focus(); e.currentTarget.setPointerCapture(e.pointerId);
|
|
65
|
+
gesture.current={id:e.pointerId,...p,box:{...saved},mode,latest:{...saved}}; edit.begin(snap,{...saved});
|
|
66
|
+
}}
|
|
67
|
+
onPointerMove={e => {
|
|
68
|
+
const p=point(e), g=gesture.current;
|
|
69
|
+
if(!g) { const mode=settings.visible ? hit(p) : ""; e.currentTarget.style.cursor=mode==="move"?"grab":mode==="nw"||mode==="se"?"nwse-resize":mode?"nesw-resize":"default"; return; }
|
|
70
|
+
if(g.id!==e.pointerId) return;
|
|
71
|
+
const dx=p.x-g.x,dy=p.y-g.y,b=g.box; let next:Box;
|
|
72
|
+
if(g.mode==="move") next={...b,x:clamp(b.x+dx,0,720-b.width),y:clamp(b.y+dy,0,480-b.height)};
|
|
73
|
+
else {
|
|
74
|
+
const left=g.mode.includes("w")?clamp(b.x+dx,0,b.x+b.width-64):b.x;
|
|
75
|
+
const top=g.mode.includes("n")?clamp(b.y+dy,0,b.y+b.height-64):b.y;
|
|
76
|
+
const right=g.mode.includes("e")?clamp(b.x+b.width+dx,b.x+64,720):b.x+b.width;
|
|
77
|
+
const bottom=g.mode.includes("s")?clamp(b.y+b.height+dy,b.y+64,480):b.y+b.height;
|
|
78
|
+
next={x:left,y:top,width:right-left,height:bottom-top};
|
|
79
|
+
}
|
|
80
|
+
g.latest=Object.fromEntries(Object.entries(next).map(([k,v])=>[k,Math.round(v)])) as Box; edit.setValue(g.latest);
|
|
81
|
+
}}
|
|
82
|
+
onPointerUp={e => { const g=gesture.current;if(!g||g.id!==e.pointerId)return;gesture.current=null;e.currentTarget.releasePointerCapture(e.pointerId);void edit.run("set_box",g.latest).catch(()=>{}); }}
|
|
83
|
+
onPointerCancel={cancel} onLostPointerCapture={cancel} />
|
|
84
|
+
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400"><span>Drag to move · Resize from corners · Esc to cancel</span><span className="font-mono">{Math.round(box.width)} × {Math.round(box.height)}</span></div>
|
|
85
|
+
<div className="mt-5 grid grid-cols-4 gap-3">
|
|
86
|
+
{(["x","y","width","height"] as const).map(key=><label key={key}><span className="label">{({x:"X",y:"Y",width:"Width",height:"Height"})[key]}</span><input className="field" aria-label={`Box ${key}`} type="number" min={key==="x"||key==="y"?0:64} max={key==="x"||key==="width"?720:480} value={box[key]} disabled={edit.pending} onChange={e=>{if(!edit.active)edit.begin(snap,{...saved});edit.setValue({...box,[key]:Number(e.target.value)});}} /></label>)}
|
|
87
|
+
</div>
|
|
88
|
+
{edit.active && <EditActions edit={edit} snap={snap} name="set_box" args={v=>({...v})} current={JSON.stringify(saved)} />}
|
|
89
|
+
</div>
|
|
90
|
+
</section>;
|
|
91
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Example for movable lists. Keep drafts in a Map outside the columns.
|
|
2
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { refreshDoc, type Observed, useDoc } from "duet-mcp/react";
|
|
4
|
+
import { EditSession } from "duet-mcp/react";
|
|
5
|
+
|
|
6
|
+
type CardsDoc = { cards: { id: string; column: string; title: string }[] };
|
|
7
|
+
export function CardEditingExample() {
|
|
8
|
+
const snap = useDoc<CardsDoc>();
|
|
9
|
+
const [editors] = useState(() => new Map<string, EditSession<string>>());
|
|
10
|
+
if (!snap) return null;
|
|
11
|
+
// Keep deleted cards visible while editing so their drafts can be recovered.
|
|
12
|
+
const ids = [...new Set([...snap.doc.cards.map(c => c.id), ...editors.keys()])];
|
|
13
|
+
return <>{["todo", "done", "deleted"].map(column => <section key={column}>
|
|
14
|
+
<h2>{column}</h2>
|
|
15
|
+
{ids.filter(id => (snap.doc.cards.find(c => c.id === id)?.column ?? "deleted") === column).map(id => {
|
|
16
|
+
let editor = editors.get(id);
|
|
17
|
+
if (!editor) { editor = new EditSession<string>(); editors.set(id, editor); }
|
|
18
|
+
return <CardEditor key={id} id={id} snap={snap} editor={editor} />;
|
|
19
|
+
})}
|
|
20
|
+
</section>)}</>;
|
|
21
|
+
}
|
|
22
|
+
function CardEditor({id,snap,editor}:{id:string;snap:Observed<CardsDoc>;editor:EditSession<string>}) {
|
|
23
|
+
const state=useSyncExternalStore(editor.subscribe,editor.getSnapshot,editor.getSnapshot);
|
|
24
|
+
const [confirmed, setConfirmed] = useState(false);
|
|
25
|
+
useEffect(() => { setConfirmed(false); }, [state.error]);
|
|
26
|
+
const card=snap.doc.cards.find(c=>c.id===id);
|
|
27
|
+
const value=state.active ? state.value! : card?.title ?? "";
|
|
28
|
+
const blocked=!!state.error || !!(state.result && "conflict" in state.result);
|
|
29
|
+
return <div>
|
|
30
|
+
<input aria-label={id} value={value} disabled={state.pending} onChange={e=>{
|
|
31
|
+
if(!state.active)editor.begin(snap,card?.title??"");
|
|
32
|
+
editor.setValue(e.target.value);
|
|
33
|
+
}}/>
|
|
34
|
+
<button disabled={!card || !state.active || state.pending || blocked}
|
|
35
|
+
onClick={()=>{void editor.run("rename_card",{cardId:id,title:value}).catch(()=>{});}}>Save</button>
|
|
36
|
+
<button disabled={!state.active || state.pending} onClick={editor.cancel}>Cancel</button>
|
|
37
|
+
{!card && <span>This card was deleted. You can still copy your draft.</span>}
|
|
38
|
+
{blocked && <section>
|
|
39
|
+
<p>{state.error ?? "The document changed while you were editing."}</p>
|
|
40
|
+
<p>Current: {card?.title ?? "(deleted)"} / Draft: {value}</p>
|
|
41
|
+
<button onClick={() => { void refreshDoc().then(() => setConfirmed(true)); }}>Refresh current values</button>
|
|
42
|
+
<button disabled={!card || state.pending || (!!state.error && !confirmed)} onClick={() => {
|
|
43
|
+
editor.restart(snap, value);
|
|
44
|
+
void editor.run("rename_card", { cardId: id, title: value }).catch(() => {});
|
|
45
|
+
}}>Review and apply draft</button>
|
|
46
|
+
</section>}
|
|
47
|
+
</div>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { refreshDoc, useEdit, type Observed } from "duet-mcp/react";
|
|
3
|
+
import type { Doc } from "../doc";
|
|
4
|
+
|
|
5
|
+
export function EditActions<T>({ edit, snap, name, args, current }: {
|
|
6
|
+
edit: ReturnType<typeof useEdit<T>>; snap: Observed<Doc>; name: string;
|
|
7
|
+
args: (value: T) => Record<string, unknown>; current: string;
|
|
8
|
+
}) {
|
|
9
|
+
const [confirmedError, setConfirmedError] = useState<string | null>(null);
|
|
10
|
+
const [refreshError, setRefreshError] = useState("");
|
|
11
|
+
const conflict = !!edit.result && "conflict" in edit.result;
|
|
12
|
+
const rejected = edit.result && "rejected" in edit.result ? edit.result.rejected : null;
|
|
13
|
+
const send = () => { setConfirmedError(null); void edit.run(name, args(edit.value!)).catch(() => {}); };
|
|
14
|
+
return <div className="mt-4 space-y-3">
|
|
15
|
+
<div className="flex items-center gap-2">
|
|
16
|
+
<button className="primary" disabled={!edit.active || edit.pending || conflict || !!edit.error} onClick={send}>Apply</button>
|
|
17
|
+
<button className="secondary" disabled={!edit.active || edit.pending} onClick={edit.cancel}>Cancel</button>
|
|
18
|
+
<span className="text-xs text-slate-400" role="status">{edit.pending ? "Saving…" : edit.active ? "Unsaved changes" : "Saved"}</span>
|
|
19
|
+
</div>
|
|
20
|
+
{rejected && <p role="alert" className="text-xs text-rose-600">{rejected}</p>}
|
|
21
|
+
{(conflict || edit.error) && <section className="space-y-3 rounded-xl bg-amber-50 p-3 text-xs" aria-label="Review changes">
|
|
22
|
+
<p role="alert">{edit.error ?? "The document changed while you were editing. Review the current values and your draft."}</p>
|
|
23
|
+
<p className="break-words">Current: {current}</p>
|
|
24
|
+
<p className="break-words">Draft: {JSON.stringify(edit.value)}</p>
|
|
25
|
+
<button className="secondary" onClick={() => { void refreshDoc().then(() => {setConfirmedError(edit.error);setRefreshError("");}).catch(e => setRefreshError(String(e))); }}>Refresh current values</button>
|
|
26
|
+
{refreshError && <p role="alert">{refreshError}</p>}
|
|
27
|
+
<button className="primary" disabled={edit.pending || (!!edit.error && confirmedError !== edit.error)} onClick={() => { edit.restart(snap, edit.value!); send(); }}>Review and apply draft</button>
|
|
28
|
+
</section>}
|
|
29
|
+
</div>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>duet — Studio playground</title>
|
|
7
|
+
<style>
|
|
8
|
+
html, body { margin: 0; background: #f5f5f5; }
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
<script type="module" src="./main.tsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createRoot } from "react-dom/client";
|
|
2
|
+
import { useDoc, useEdit } from "duet-mcp/react";
|
|
3
|
+
import { defaultSettings, initialDoc, type Doc, type Settings } from "../doc";
|
|
4
|
+
import { EditActions } from "./edit-actions";
|
|
5
|
+
import { Canvas } from "./canvas";
|
|
6
|
+
import "./style.css";
|
|
7
|
+
|
|
8
|
+
function App() {
|
|
9
|
+
const snap = useDoc<Doc>();
|
|
10
|
+
const edit = useEdit<Settings>();
|
|
11
|
+
const textEdit = useEdit<string>();
|
|
12
|
+
if (!snap) return <main className="p-12 text-sm text-slate-500">Connecting to the studio…</main>;
|
|
13
|
+
// Provide defaults for text-only snapshots. Save new fields through their operations.
|
|
14
|
+
const saved = snap.doc.settings ?? defaultSettings();
|
|
15
|
+
const settings = edit.active ? edit.value! : saved;
|
|
16
|
+
const set = <K extends keyof Settings>(key: K, value: Settings[K]) => {
|
|
17
|
+
if (!edit.active) edit.begin(snap, { ...saved });
|
|
18
|
+
edit.setValue({ ...settings, [key]: value });
|
|
19
|
+
};
|
|
20
|
+
return <div id="studio" className="min-h-screen">
|
|
21
|
+
<header className="border-b border-slate-200/80 bg-white">
|
|
22
|
+
<div className="mx-auto flex max-w-[1440px] items-center justify-between px-5 py-5 sm:px-10">
|
|
23
|
+
<div className="flex items-center gap-3"><span className="flex h-9 w-9 items-center justify-center rounded-xl bg-violet-600 text-xl font-bold text-white">d.</span><span className="text-lg font-semibold tracking-tight">duet<span className="ml-3 border-l border-slate-200 pl-3 text-sm font-normal text-slate-400">playground</span></span></div>
|
|
24
|
+
<div className="flex items-center gap-2 rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1.5 text-xs text-emerald-700"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500"/>Live<span className="hidden sm:inline"> · {snap.actor}</span></div>
|
|
25
|
+
</div>
|
|
26
|
+
</header>
|
|
27
|
+
<main className="mx-auto max-w-[1440px] px-5 py-8 sm:px-10 sm:py-10">
|
|
28
|
+
<div className="mb-8 flex flex-wrap items-end justify-between gap-4"><div><p className="mb-2 text-[10px] font-bold tracking-[.2em] text-violet-600">YOUR SHARED CREATIVE SPACE</p><h1 className="text-3xl font-semibold tracking-tight">A little space for big ideas.</h1><p className="mt-3 text-sm text-slate-500">Tweak the controls. Move things around. One studio for you and AI.</p></div><span className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-500">Studio / 01</span></div>
|
|
29
|
+
<div className="grid items-start gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
|
|
30
|
+
<section className="panel" aria-label="Design settings">
|
|
31
|
+
<div className="border-b border-slate-100 px-6 py-4"><h2 className="text-sm font-semibold">Design controls</h2><p className="mt-1 text-xs text-slate-400">Apply your settings to update the canvas.</p></div>
|
|
32
|
+
<div className="space-y-5 p-6"><fieldset disabled={edit.pending} className="space-y-5">
|
|
33
|
+
<label className="block"><span className="label">Heading</span><input className="field" maxLength={80} value={settings.caption} onChange={e=>set("caption",e.target.value)} /></label>
|
|
34
|
+
<label className="block"><span className="label">Notes</span><textarea className="field min-h-20 resize-y" maxLength={500} value={settings.notes} onChange={e=>set("notes",e.target.value)} /></label>
|
|
35
|
+
<div className="border-t border-slate-100 pt-5"><span className="label">Display options</span><div className="space-y-3 text-sm"><label className="flex items-center gap-2.5"><input type="checkbox" className="h-4 w-4" checked={settings.visible} onChange={e=>set("visible",e.target.checked)} />Show box</label><label className="flex items-center gap-2.5"><input type="checkbox" className="h-4 w-4" checked={settings.grid} onChange={e=>set("grid",e.target.checked)} />Dot grid</label></div></div>
|
|
36
|
+
<fieldset><legend className="label">Style</legend><div className="grid grid-cols-2 gap-2">{([['solid','Solid'],['outline','Outline']] as const).map(([v,label])=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border p-3 text-xs ${settings.style===v?'border-violet-300 bg-violet-50 text-violet-700':'border-slate-200'}`}><input type="radio" name="style" value={v} checked={settings.style===v} onChange={()=>set("style",v)}/>{label}</label>)}</div></fieldset>
|
|
37
|
+
<label className="block"><span className="label">Accent color</span><select className="field" value={settings.color} onChange={e=>set("color",e.target.value as Settings['color'])}><option value="violet">Violet</option><option value="blue">Blue</option><option value="coral">Coral</option></select></label>
|
|
38
|
+
<label className="block"><span className="label flex justify-between">Opacity<span className="font-mono text-violet-600">{settings.opacity}%</span></span><input className="w-full" type="range" min={10} max={100} value={settings.opacity} onChange={e=>set("opacity",Number(e.target.value))}/></label>
|
|
39
|
+
</fieldset><EditActions edit={edit} snap={snap} name="set_settings" args={v=>({...v})} current={JSON.stringify(saved)}/></div>
|
|
40
|
+
</section>
|
|
41
|
+
<div className="space-y-6"><Canvas snap={snap} settings={saved} box={snap.doc.box ?? initialDoc().box}/>
|
|
42
|
+
<section className="panel p-6" aria-label="Shared note"><div className="mb-4 flex items-center gap-2"><span className="text-violet-500">✦</span><h2 className="text-sm font-semibold">Shared note</h2></div><p className="mb-4 whitespace-pre-wrap text-sm text-slate-500">{saved.notes}</p>
|
|
43
|
+
<label htmlFor="text" className="label">Text</label><input id="text" className="field" placeholder="Leave a note for your collaborator…" disabled={textEdit.pending} value={textEdit.active?textEdit.value!:snap.doc.text} onChange={e=>{if(!textEdit.active)textEdit.begin(snap,snap.doc.text);textEdit.setValue(e.target.value);}} />
|
|
44
|
+
<EditActions edit={textEdit} snap={snap} name="set_text" args={text=>({text})} current={snap.doc.text}/>
|
|
45
|
+
<div id="shot" className="mt-4 whitespace-pre-wrap rounded-lg bg-slate-50 p-4 text-sm text-slate-600">{snap.doc.text || "No notes yet."}</div>
|
|
46
|
+
</section>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<footer className="mt-8 flex flex-wrap justify-between gap-2 text-[11px] text-slate-400"><span>Made for two. Built with duet.</span><span>Changes are shared with everyone in this studio.</span></footer>
|
|
50
|
+
</main>
|
|
51
|
+
</div>;
|
|
52
|
+
}
|
|
53
|
+
createRoot(document.getElementById("root")!).render(<App />);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@theme { --font-sans: "Inter", "Hiragino Sans", "Noto Sans", system-ui, sans-serif; }
|
|
3
|
+
@layer base {
|
|
4
|
+
body { @apply m-0 bg-[#f5f5f7] text-slate-800 antialiased; }
|
|
5
|
+
button, input, select, textarea { @apply accent-violet-600; }
|
|
6
|
+
button { @apply cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-40; }
|
|
7
|
+
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, canvas:focus-visible { @apply outline-2 outline-offset-4 outline-violet-500; }
|
|
8
|
+
}
|
|
9
|
+
@layer components {
|
|
10
|
+
.panel { @apply rounded-2xl border border-slate-200/80 bg-white shadow-sm; }
|
|
11
|
+
.field { @apply w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-violet-400; }
|
|
12
|
+
.label { @apply mb-2 block text-xs font-semibold text-slate-600; }
|
|
13
|
+
.primary { @apply rounded-lg bg-violet-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-violet-700; }
|
|
14
|
+
.secondary { @apply rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-xs font-medium hover:bg-slate-50; }
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"types": ["node"]
|
|
12
|
+
},
|
|
13
|
+
// Include UI files and shared document types; keep server modules out of this build.
|
|
14
|
+
"include": ["*.ts", "*.tsx", "../doc.ts"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { defineConfig } from "vite";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
5
|
+
import { portFor } from "duet-mcp/wire";
|
|
6
|
+
|
|
7
|
+
// Match the directory name to app.id when copying or renaming the template.
|
|
8
|
+
const appId = path.basename(path.resolve(__dirname, ".."));
|
|
9
|
+
|
|
10
|
+
// Use the same port derivation as the server.
|
|
11
|
+
const target = `http://127.0.0.1:${portFor(appId)}`;
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
root: __dirname,
|
|
15
|
+
plugins: [react(), tailwindcss()],
|
|
16
|
+
build: { outDir: "dist", emptyOutDir: true },
|
|
17
|
+
server: {
|
|
18
|
+
port: 5173,
|
|
19
|
+
proxy: {
|
|
20
|
+
"/api": { target, changeOrigin: true },
|
|
21
|
+
"/blob": { target, changeOrigin: true },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|