@schlessera/brain-ui-react 0.10.0 → 0.12.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/dist/components/chat/brain-markdown.d.ts +8 -0
- package/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +30 -0
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +2 -1
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/images/mask-editor.d.ts +5 -0
- package/dist/components/images/mask-editor.d.ts.map +1 -0
- package/dist/components/images/mask-editor.js +135 -0
- package/dist/components/images/mask-editor.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +11 -0
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/stores/mask-store.d.ts +22 -0
- package/dist/stores/mask-store.d.ts.map +1 -0
- package/dist/stores/mask-store.js +7 -0
- package/dist/stores/mask-store.js.map +1 -0
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/brain-markdown.tsx +36 -0
- package/src/components/chat/chat-page.tsx +10 -0
- package/src/components/images/mask-editor.tsx +253 -0
- package/src/hooks/use-websocket.ts +12 -0
- package/src/index.ts +2 -0
- package/src/stores/mask-store.ts +28 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Eraser, RotateCcw, X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { API_BASE } from "../../lib/backend.js";
|
|
5
|
+
import { useMaskStore } from "../../stores/mask-store.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Paint-over-the-image mask editor.
|
|
9
|
+
*
|
|
10
|
+
* The agent cannot know which part of a picture someone means, so it asks. The
|
|
11
|
+
* user paints; this produces a PNG the same size as the source image in which
|
|
12
|
+
* **painted pixels are fully transparent** and everything else is opaque —
|
|
13
|
+
* the convention OpenAI's edit endpoint reads, so the bytes go through to the
|
|
14
|
+
* API unmodified.
|
|
15
|
+
*
|
|
16
|
+
* Painting happens on a display-sized canvas and is scaled to the image's true
|
|
17
|
+
* pixel dimensions on export, so a mask drawn on a phone still lines up with a
|
|
18
|
+
* 4K source.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface Stroke {
|
|
22
|
+
points: { x: number; y: number }[];
|
|
23
|
+
/** Brush width in display pixels. */
|
|
24
|
+
width: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function MaskEditor({
|
|
28
|
+
onSubmit,
|
|
29
|
+
onCancel,
|
|
30
|
+
}: {
|
|
31
|
+
onSubmit: (requestId: string, maskPngBase64: string) => void;
|
|
32
|
+
onCancel: (requestId: string, message: string) => void;
|
|
33
|
+
}) {
|
|
34
|
+
const request = useMaskStore((s) => s.request);
|
|
35
|
+
const close = useMaskStore((s) => s.close);
|
|
36
|
+
|
|
37
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
38
|
+
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
39
|
+
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
|
40
|
+
const [drawing, setDrawing] = useState(false);
|
|
41
|
+
const [brush, setBrush] = useState(48);
|
|
42
|
+
const [loaded, setLoaded] = useState(false);
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
const rawUrl = request
|
|
46
|
+
? `${API_BASE}/files/content?path=${encodeURIComponent(request.imagePath)}&raw=1`
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
// Reset per request: a second mask on a different image must not inherit the
|
|
50
|
+
// first one's strokes.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setStrokes([]);
|
|
53
|
+
setLoaded(false);
|
|
54
|
+
setError(null);
|
|
55
|
+
}, [request?.requestId]);
|
|
56
|
+
|
|
57
|
+
const redraw = useCallback(() => {
|
|
58
|
+
const canvas = canvasRef.current;
|
|
59
|
+
const img = imageRef.current;
|
|
60
|
+
if (!canvas || !img) return;
|
|
61
|
+
const ctx = canvas.getContext("2d");
|
|
62
|
+
if (!ctx) return;
|
|
63
|
+
|
|
64
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
65
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
66
|
+
|
|
67
|
+
// The painted region is shown as a translucent wash so the user can still
|
|
68
|
+
// see what is underneath it while deciding.
|
|
69
|
+
ctx.save();
|
|
70
|
+
ctx.strokeStyle = "rgba(239, 68, 68, 0.55)";
|
|
71
|
+
ctx.lineCap = "round";
|
|
72
|
+
ctx.lineJoin = "round";
|
|
73
|
+
for (const stroke of strokes) {
|
|
74
|
+
ctx.lineWidth = stroke.width;
|
|
75
|
+
ctx.beginPath();
|
|
76
|
+
stroke.points.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
|
|
77
|
+
// A single tap is a dot, not a zero-length line.
|
|
78
|
+
if (stroke.points.length === 1) ctx.lineTo(stroke.points[0].x + 0.01, stroke.points[0].y);
|
|
79
|
+
ctx.stroke();
|
|
80
|
+
}
|
|
81
|
+
ctx.restore();
|
|
82
|
+
}, [strokes]);
|
|
83
|
+
|
|
84
|
+
useEffect(redraw, [redraw, loaded]);
|
|
85
|
+
|
|
86
|
+
const pointFrom = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
87
|
+
const canvas = canvasRef.current!;
|
|
88
|
+
const rect = canvas.getBoundingClientRect();
|
|
89
|
+
return {
|
|
90
|
+
x: ((e.clientX - rect.left) / rect.width) * canvas.width,
|
|
91
|
+
y: ((e.clientY - rect.top) / rect.height) * canvas.height,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (!request) return null;
|
|
96
|
+
|
|
97
|
+
const submit = () => {
|
|
98
|
+
const canvas = canvasRef.current;
|
|
99
|
+
const img = imageRef.current;
|
|
100
|
+
if (!canvas || !img) return;
|
|
101
|
+
if (strokes.length === 0) {
|
|
102
|
+
setError("Paint over the area you want changed first.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Export at the source image's real dimensions, not the display size.
|
|
107
|
+
const out = document.createElement("canvas");
|
|
108
|
+
out.width = img.naturalWidth;
|
|
109
|
+
out.height = img.naturalHeight;
|
|
110
|
+
const ctx = out.getContext("2d");
|
|
111
|
+
if (!ctx) {
|
|
112
|
+
setError("This browser could not produce the mask.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const scale = out.width / canvas.width;
|
|
116
|
+
|
|
117
|
+
ctx.fillStyle = "#ffffff";
|
|
118
|
+
ctx.fillRect(0, 0, out.width, out.height);
|
|
119
|
+
// Painted area becomes a hole: alpha 0 is what marks it editable.
|
|
120
|
+
ctx.globalCompositeOperation = "destination-out";
|
|
121
|
+
ctx.strokeStyle = "rgba(0,0,0,1)";
|
|
122
|
+
ctx.lineCap = "round";
|
|
123
|
+
ctx.lineJoin = "round";
|
|
124
|
+
for (const stroke of strokes) {
|
|
125
|
+
ctx.lineWidth = stroke.width * scale;
|
|
126
|
+
ctx.beginPath();
|
|
127
|
+
stroke.points.forEach((p, i) =>
|
|
128
|
+
i === 0 ? ctx.moveTo(p.x * scale, p.y * scale) : ctx.lineTo(p.x * scale, p.y * scale)
|
|
129
|
+
);
|
|
130
|
+
if (stroke.points.length === 1) {
|
|
131
|
+
ctx.lineTo(stroke.points[0].x * scale + 0.01, stroke.points[0].y * scale);
|
|
132
|
+
}
|
|
133
|
+
ctx.stroke();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const dataUrl = out.toDataURL("image/png");
|
|
137
|
+
onSubmit(request.requestId, dataUrl.slice(dataUrl.indexOf(",") + 1));
|
|
138
|
+
close();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const cancel = () => {
|
|
142
|
+
onCancel(request.requestId, "The user closed the mask editor");
|
|
143
|
+
close();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="fixed inset-0 z-50 flex flex-col bg-black/80 backdrop-blur-sm">
|
|
148
|
+
<div className="flex items-center justify-between gap-3 p-3 text-sm text-white">
|
|
149
|
+
<div className="min-w-0">
|
|
150
|
+
<div className="font-medium">Mark the area to change</div>
|
|
151
|
+
<div className="truncate text-white/70">
|
|
152
|
+
{request.instruction ?? request.imagePath}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={cancel}
|
|
158
|
+
aria-label="Cancel"
|
|
159
|
+
className="rounded p-2 hover:bg-white/10"
|
|
160
|
+
>
|
|
161
|
+
<X className="h-5 w-5" />
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="flex min-h-0 flex-1 items-center justify-center p-3">
|
|
166
|
+
{rawUrl && (
|
|
167
|
+
<img
|
|
168
|
+
ref={imageRef}
|
|
169
|
+
src={rawUrl}
|
|
170
|
+
alt=""
|
|
171
|
+
className="hidden"
|
|
172
|
+
onLoad={(e) => {
|
|
173
|
+
const img = e.currentTarget;
|
|
174
|
+
const canvas = canvasRef.current;
|
|
175
|
+
if (canvas) {
|
|
176
|
+
// Cap the working canvas so a 4K source stays responsive to draw on.
|
|
177
|
+
const maxEdge = 1400;
|
|
178
|
+
const scale = Math.min(1, maxEdge / Math.max(img.naturalWidth, img.naturalHeight));
|
|
179
|
+
canvas.width = Math.round(img.naturalWidth * scale);
|
|
180
|
+
canvas.height = Math.round(img.naturalHeight * scale);
|
|
181
|
+
}
|
|
182
|
+
setLoaded(true);
|
|
183
|
+
}}
|
|
184
|
+
onError={() => setError(`Could not load ${request.imagePath}`)}
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
<canvas
|
|
188
|
+
ref={canvasRef}
|
|
189
|
+
className="max-h-full max-w-full touch-none rounded shadow-lg"
|
|
190
|
+
onPointerDown={(e) => {
|
|
191
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
192
|
+
setDrawing(true);
|
|
193
|
+
setStrokes((prev) => [...prev, { points: [pointFrom(e)], width: brush }]);
|
|
194
|
+
}}
|
|
195
|
+
onPointerMove={(e) => {
|
|
196
|
+
if (!drawing) return;
|
|
197
|
+
const p = pointFrom(e);
|
|
198
|
+
setStrokes((prev) => {
|
|
199
|
+
const next = [...prev];
|
|
200
|
+
const last = next[next.length - 1];
|
|
201
|
+
if (last) next[next.length - 1] = { ...last, points: [...last.points, p] };
|
|
202
|
+
return next;
|
|
203
|
+
});
|
|
204
|
+
}}
|
|
205
|
+
onPointerUp={() => setDrawing(false)}
|
|
206
|
+
onPointerCancel={() => setDrawing(false)}
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{error && <div className="px-3 pb-1 text-center text-sm text-red-300">{error}</div>}
|
|
211
|
+
|
|
212
|
+
<div className="flex items-center gap-3 p-3">
|
|
213
|
+
<label className="flex flex-1 items-center gap-2 text-xs text-white/80">
|
|
214
|
+
Brush
|
|
215
|
+
<input
|
|
216
|
+
type="range"
|
|
217
|
+
min={8}
|
|
218
|
+
max={160}
|
|
219
|
+
value={brush}
|
|
220
|
+
onChange={(e) => setBrush(Number(e.target.value))}
|
|
221
|
+
className="flex-1"
|
|
222
|
+
/>
|
|
223
|
+
</label>
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onClick={() => setStrokes((prev) => prev.slice(0, -1))}
|
|
227
|
+
disabled={strokes.length === 0}
|
|
228
|
+
className="rounded p-2 text-white hover:bg-white/10 disabled:opacity-40"
|
|
229
|
+
aria-label="Undo"
|
|
230
|
+
>
|
|
231
|
+
<RotateCcw className="h-5 w-5" />
|
|
232
|
+
</button>
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={() => setStrokes([])}
|
|
236
|
+
disabled={strokes.length === 0}
|
|
237
|
+
className="rounded p-2 text-white hover:bg-white/10 disabled:opacity-40"
|
|
238
|
+
aria-label="Clear"
|
|
239
|
+
>
|
|
240
|
+
<Eraser className="h-5 w-5" />
|
|
241
|
+
</button>
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
onClick={submit}
|
|
245
|
+
disabled={!loaded}
|
|
246
|
+
className="rounded bg-white px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
|
|
247
|
+
>
|
|
248
|
+
Use this area
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -3,6 +3,7 @@ import { WSClient } from "../lib/ws-client.js";
|
|
|
3
3
|
import { useConnectionStore } from "../stores/connection-store.js";
|
|
4
4
|
import { useChatStore, activeChat, type ChatKey } from "../stores/chat-store.js";
|
|
5
5
|
import { useProviderStore } from "../stores/provider-store.js";
|
|
6
|
+
import { useMaskStore } from "../stores/mask-store.js";
|
|
6
7
|
import { getWsUrl } from "../lib/backend.js";
|
|
7
8
|
import type {
|
|
8
9
|
ServerMessage,
|
|
@@ -190,6 +191,17 @@ export function handleServerMessage(msg: ServerMessage) {
|
|
|
190
191
|
requestBrowserLocation(msg);
|
|
191
192
|
break;
|
|
192
193
|
|
|
194
|
+
case "mask_request":
|
|
195
|
+
// Opens the editor; the answer travels back from the component, because
|
|
196
|
+
// only the user can say which part of the picture they meant.
|
|
197
|
+
useMaskStore.getState().open({
|
|
198
|
+
requestId: msg.requestId,
|
|
199
|
+
imagePath: msg.imagePath,
|
|
200
|
+
...(msg.instruction ? { instruction: msg.instruction } : {}),
|
|
201
|
+
...(msg.turnId ? { turnId: msg.turnId } : {}),
|
|
202
|
+
});
|
|
203
|
+
break;
|
|
204
|
+
|
|
193
205
|
case "result":
|
|
194
206
|
state.finishAssistantMessage(key);
|
|
195
207
|
resyncIfNeeded(msg.sessionId);
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,8 @@ export {
|
|
|
38
38
|
type MessageAttachment,
|
|
39
39
|
} from "./stores/chat-store.js";
|
|
40
40
|
export { useFileStore } from "./stores/file-store.js";
|
|
41
|
+
export { useMaskStore, type MaskRequest } from "./stores/mask-store.js";
|
|
42
|
+
export { MaskEditor } from "./components/images/mask-editor.js";
|
|
41
43
|
export { useUIStore, type ActiveView } from "./stores/ui-store.js";
|
|
42
44
|
export { useGraphStore, type GraphMode } from "./stores/graph-store.js";
|
|
43
45
|
export { useConnectionStore } from "./stores/connection-store.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pending mask request, if any.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately a small standalone store rather than chat-buffer state: a mask
|
|
7
|
+
* request is modal and at most one is open at a time, and nothing about it
|
|
8
|
+
* needs to survive in a transcript — the answer travels back over the socket
|
|
9
|
+
* and the resulting file is what persists.
|
|
10
|
+
*/
|
|
11
|
+
export interface MaskRequest {
|
|
12
|
+
requestId: string;
|
|
13
|
+
imagePath: string;
|
|
14
|
+
instruction?: string;
|
|
15
|
+
turnId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MaskState {
|
|
19
|
+
request: MaskRequest | null;
|
|
20
|
+
open: (request: MaskRequest) => void;
|
|
21
|
+
close: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const useMaskStore = create<MaskState>((set) => ({
|
|
25
|
+
request: null,
|
|
26
|
+
open: (request) => set({ request }),
|
|
27
|
+
close: () => set({ request: null }),
|
|
28
|
+
}));
|