- Claude Code as an engine: chats on a "claude-code/<alias>" model run through the claude CLI (stream-json); tool approvals go through our permission system. Sign in from the web UI (URL + pasted code), CLI version and update from Settings; the Docker image contains the CLI. - Small models (8k context) for side tasks: input is cut to the model's context window; one-line chat status, "What does this do?" on approvals, "Plan as automation" (job draft from a chat). - Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark up, use in message), jump to previous own message, message menu, time under every message. - Approvals: push actions (allow/reject), "always allow in this project" (never for dangerous commands) with revoke. - Per turn: changed files with diffs and rollback; project check command with a green/red badge and "let the agent fix it". - Colour tags for chats in the colours of the active theme. - Project defaults for new chats, project page (timeline), offline queue. Tested in a throwaway container with a mock model (8k small model): 26 API checks passed (permissions, diffs, checks, defaults, job draft, side-task budget, Claude Code status/login URL, unsigned-in chat). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useT } from "../i18n";
|
|
import { toast } from "../dialogs";
|
|
import { Icon } from "./Icon";
|
|
|
|
type Tool = "pen" | "arrow" | "rect";
|
|
interface Shape {
|
|
tool: Tool;
|
|
color: string;
|
|
/** pen: all points; arrow/rect: start and end. */
|
|
pts: { x: number; y: number }[];
|
|
}
|
|
|
|
const COLORS = ["#ff3b30", "#ffcc00", "#34c759", "#0a84ff", "#ffffff", "#000000"];
|
|
const MAX_SIDE = 2000;
|
|
|
|
function paint(ctx: CanvasRenderingContext2D, shapes: Shape[], w: number) {
|
|
const lw = Math.max(3, Math.round(w / 280));
|
|
for (const s of shapes) {
|
|
ctx.strokeStyle = s.color;
|
|
ctx.fillStyle = s.color;
|
|
ctx.lineWidth = lw;
|
|
ctx.lineCap = "round";
|
|
ctx.lineJoin = "round";
|
|
const a = s.pts[0];
|
|
const b = s.pts[s.pts.length - 1];
|
|
ctx.beginPath();
|
|
if (s.tool === "pen") {
|
|
ctx.moveTo(a.x, a.y);
|
|
for (const p of s.pts.slice(1)) ctx.lineTo(p.x, p.y);
|
|
if (s.pts.length === 1) ctx.lineTo(a.x + 0.1, a.y);
|
|
ctx.stroke();
|
|
} else if (s.tool === "rect") {
|
|
ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
|
|
} else {
|
|
// Arrow: line with a filled head at the end.
|
|
const ang = Math.atan2(b.y - a.y, b.x - a.x);
|
|
const head = lw * 5;
|
|
ctx.moveTo(a.x, a.y);
|
|
ctx.lineTo(b.x - Math.cos(ang) * head * 0.6, b.y - Math.sin(ang) * head * 0.6);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(b.x, b.y);
|
|
ctx.lineTo(b.x - head * Math.cos(ang - 0.45), b.y - head * Math.sin(ang - 0.45));
|
|
ctx.lineTo(b.x - head * Math.cos(ang + 0.45), b.y - head * Math.sin(ang + 0.45));
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Draw on an image (pen, arrow, rectangle) and hand the result back as a PNG file. */
|
|
export function ImageAnnotator({ src, name, onDone, onCancel }: { src: string; name: string; onDone: (file: File) => void; onCancel: () => void }) {
|
|
const t = useT();
|
|
const canvas = useRef<HTMLCanvasElement>(null);
|
|
const bitmap = useRef<HTMLImageElement | undefined>(undefined);
|
|
const [shapes, setShapes] = useState<Shape[]>([]);
|
|
const [tool, setTool] = useState<Tool>("pen");
|
|
const [color, setColor] = useState(COLORS[0]);
|
|
const [ready, setReady] = useState(false);
|
|
const drawing = useRef<Shape | null>(null);
|
|
|
|
const redraw = (extra?: Shape | null) => {
|
|
const c = canvas.current;
|
|
const img = bitmap.current;
|
|
if (!c || !img) return;
|
|
const ctx = c.getContext("2d")!;
|
|
ctx.clearRect(0, 0, c.width, c.height);
|
|
ctx.drawImage(img, 0, 0, c.width, c.height);
|
|
paint(ctx, extra ? [...shapes, extra] : shapes, c.width);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const img = new Image();
|
|
img.crossOrigin = "anonymous";
|
|
img.onload = () => {
|
|
const c = canvas.current;
|
|
if (!c) return;
|
|
const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
|
|
c.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale));
|
|
c.height = Math.max(1, Math.round((img.naturalHeight || 200) * scale));
|
|
bitmap.current = img;
|
|
setReady(true);
|
|
};
|
|
img.onerror = () => {
|
|
toast(t("viewer.annotateFailed"));
|
|
onCancel();
|
|
};
|
|
img.src = src;
|
|
}, [src]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => redraw(), [shapes, ready]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const point = (e: React.PointerEvent) => {
|
|
const c = canvas.current!;
|
|
const r = c.getBoundingClientRect();
|
|
return { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height };
|
|
};
|
|
|
|
const finish = () => {
|
|
const c = canvas.current;
|
|
if (!c) return;
|
|
c.toBlob((blob) => {
|
|
if (!blob) return toast(t("viewer.annotateFailed"));
|
|
const base = name.replace(/\.[a-z0-9]{1,5}$/i, "") || "image";
|
|
onDone(new File([blob], `${base}-marked.png`, { type: "image/png" }));
|
|
}, "image/png");
|
|
};
|
|
|
|
return (
|
|
<div className="annotator" onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
|
|
<canvas
|
|
ref={canvas}
|
|
style={{ visibility: ready ? "visible" : "hidden" }}
|
|
onPointerDown={(e) => {
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
drawing.current = { tool, color, pts: [point(e)] };
|
|
}}
|
|
onPointerMove={(e) => {
|
|
const d = drawing.current;
|
|
if (!d) return;
|
|
const p = point(e);
|
|
d.pts = d.tool === "pen" ? [...d.pts, p] : [d.pts[0], p];
|
|
redraw(d);
|
|
}}
|
|
onPointerUp={() => {
|
|
const d = drawing.current;
|
|
drawing.current = null;
|
|
if (d) setShapes((s) => [...s, d]);
|
|
}}
|
|
onPointerCancel={() => {
|
|
drawing.current = null;
|
|
redraw();
|
|
}}
|
|
/>
|
|
<div className="annotator-tools" role="toolbar" aria-label={t("viewer.annotate")}>
|
|
{(["pen", "arrow", "rect"] as Tool[]).map((k) => (
|
|
<button key={k} className={`small ${tool === k ? "primary" : "ghost"}`} onClick={() => setTool(k)} aria-pressed={tool === k} title={t(`viewer.tool.${k}` as const)}>
|
|
<Icon name={k === "pen" ? "edit" : k === "arrow" ? "arrow-right" : "box-off"} />
|
|
</button>
|
|
))}
|
|
<span className="annotator-colors">
|
|
{COLORS.map((c) => (
|
|
<button key={c} className={`swatch ${c === color ? "on" : ""}`} style={{ background: c }} onClick={() => setColor(c)} aria-label={c} aria-pressed={c === color} />
|
|
))}
|
|
</span>
|
|
<button className="small ghost" onClick={() => setShapes((s) => s.slice(0, -1))} disabled={!shapes.length} title={t("viewer.undo")} aria-label={t("viewer.undo")}>
|
|
<Icon name="undo" />
|
|
</button>
|
|
<span className="grow" />
|
|
<button className="small" onClick={onCancel}>
|
|
{t("common.cancel")}
|
|
</button>
|
|
<button className="small primary" onClick={finish} disabled={!ready}>
|
|
<Icon name="clip" /> {t("viewer.attach")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|