feat(lanes,run,pricing): Changes sidebar editor, staged groups, image drop, auto pricing
- Changes panel at full screen: the whole Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab is dropped; a working-copy file opens straight into the editor (Save/Cancel live once the text differs, Cancel restores it, Whole file toggles back to the diff), and the page is height-locked so the editor no longer collapses with no live run. - Overview splits into Staged Changes / Changes groups in the work range. - A changed file's whole tree row takes its status tint. - Drag an image onto the lane terminal: POST /api/run/drop-image saves it (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path is pasted into the pane, which is how claude attaches it. - Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40); claude-opus-5% had been pricing it as Opus 5. - server/lib/pricing-sync.js: a model family used in token_usage with no row of its own is inserted from LiteLLM's public price list, never updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly that one third-party GitHub raw URL.
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -80,6 +80,13 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByText(path).closest("li")!).getByRole("button", {
|
||||
name: "Discard this file's changes",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
@@ -301,13 +308,82 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file straight into the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "main.ts", path: "src/main.ts", dir: false, status: " M" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
// The sidebar IS the Files view: its finder lives there, and the tab is gone.
|
||||
expect(within(sidebar).getByLabelText("Find a file by path…")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// No Edit click: the editor is simply there, Save/Cancel idle until typed.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel reverts to the saved text and stays in the editor.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview straight into the editor", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("a");
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
await user.click(discardOf("server/lib/git-ops.js"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
@@ -341,8 +417,7 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
await user.click(discardOf("server/lib/git-ops.js"));
|
||||
|
||||
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
|
||||
const base = changes();
|
||||
|
||||
@@ -11,15 +11,23 @@
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
@@ -45,6 +53,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
const paletteRef = useRef(palette);
|
||||
paletteRef.current = palette;
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { t } = useTranslation("run");
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// dragenter/dragleave fire for every child crossed, so count them: the hint
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees one pasted path rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
@@ -249,10 +293,44 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
className="relative h-full w-full"
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
{drop && (
|
||||
// pointer-events-none: the drop must land on the terminal, not on this.
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" || drop === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act } from "@testing-library/react";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -18,6 +18,11 @@ const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -61,6 +66,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
@@ -314,4 +320,31 @@ describe("TerminalView", () => {
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,9 @@
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
"changes.stagedGroup": "Staged Changes",
|
||||
"changes.unstagedGroup": "Changes",
|
||||
"changes.stage": "Stage this file",
|
||||
"changes.title": "Changes",
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
|
||||
@@ -164,5 +164,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop image to attach",
|
||||
"uploading": "Uploading image…",
|
||||
"failed": "Could not attach the image",
|
||||
"imagesOnly": "Only images can be dropped here"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
"changes.stagedGroup": "Đã stage",
|
||||
"changes.unstagedGroup": "Thay đổi",
|
||||
"changes.stage": "Đưa file này vào index",
|
||||
"changes.title": "Thay đổi",
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
|
||||
@@ -163,5 +163,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả ảnh để đính kèm",
|
||||
"uploading": "Đang tải ảnh lên…",
|
||||
"failed": "Không đính kèm được ảnh",
|
||||
"imagesOnly": "Chỉ thả được ảnh vào đây"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1982,6 +1982,14 @@ export const api = {
|
||||
if (q) qs.set("q", q);
|
||||
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
||||
},
|
||||
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
||||
* server's machine; the returned path is what gets pasted into the pane. */
|
||||
dropImage: (file: Blob) =>
|
||||
request<{ path: string }>("/run/drop-image", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
|
||||
@@ -583,8 +583,11 @@ export function Workspace() {
|
||||
|
||||
// Viewport locked only when a live run is showing (TerminalView needs locked
|
||||
// viewport for chat scrolling). The config-card screen needs normal page flow
|
||||
// so the form is fully reachable on short windows.
|
||||
const viewportLocked = paneHasActiveRun;
|
||||
// so the form is fully reachable on short windows. A full-width Changes panel
|
||||
// hides that form anyway, and without a locked height it shrinks to its
|
||||
// content — the editor inside it collapsed to a few lines (no live run).
|
||||
const viewportLocked =
|
||||
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -1032,6 +1035,7 @@ export function Workspace() {
|
||||
</div>
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
|
||||
@@ -75,6 +75,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
gitTree: r({ entries: [] }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
|
||||
@@ -1994,6 +1994,7 @@ GET /api/run/binary?tool= { found, path } for the `claude` or `codex
|
||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
|
||||
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
|
||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
|
||||
GET /api/run/:id Run handle (returns live run state)
|
||||
|
||||
+3
-1
@@ -389,7 +389,9 @@ CREATE TABLE notifications (
|
||||
|
||||
### model_pricing
|
||||
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD).
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
||||
|
||||
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` suffix) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_pricing (
|
||||
|
||||
+12
-2
@@ -575,7 +575,13 @@ says more than any sentence the dashboard could write.
|
||||
|
||||
To the right of the lane detail sits a collapsible **Changes** card (its state
|
||||
persists per viewer in `localStorage`). Its header holds **one** control, which
|
||||
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted) → closed (`agenthub.workspace.changesSize`
|
||||
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted; the page is
|
||||
height-locked so the panel fills the viewport even with no live run; the whole
|
||||
Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab
|
||||
is dropped, and every working-copy file opened there, from the sidebar, Overview or Review, IS
|
||||
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no
|
||||
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
|
||||
restores the saved text) → closed (`agenthub.workspace.changesSize`
|
||||
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
|
||||
narrow. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
|
||||
which one is "work" changes several times an hour, so the width is a step, not
|
||||
@@ -667,7 +673,11 @@ there is nothing it could mean. It is the dashboard's ONLY commit: the git bar
|
||||
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
|
||||
irreversible action in the panel: it asks twice in the UI, and the server
|
||||
refuses it without `confirm: true` and accepts only paths `git status` is
|
||||
already reporting as changed.
|
||||
already reporting as changed. In the `work` range the overview splits like VS
|
||||
Code's Source Control: **Staged Changes** (what the next commit takes, each row
|
||||
offering Unstage) above **Changes** (unstaged and untracked, each offering
|
||||
Stage); a partly staged file appears in both. In the tree, a changed file's
|
||||
whole row takes its status tint, not only its letter.
|
||||
|
||||
Review, single-file, history and browse do **not** poll: re-fetching under the
|
||||
reader's scroll position is worse than a slightly stale view, and those are the
|
||||
|
||||
@@ -703,6 +703,7 @@ HTTP surface for the tmux-backed terminal run: one session per lane, named `agen
|
||||
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
|
||||
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
|
||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||
| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
||||
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
|
||||
|
||||
@@ -324,3 +324,42 @@ describe("calculateCost — date-effective (intro) pricing", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_PRICING — Opus 5.5", () => {
|
||||
it("prices claude-opus-5-5 at its own rates, not Opus 5's", () => {
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`pricing-opus55-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { DEFAULT_PRICING } = require("../db");
|
||||
const rules = DEFAULT_PRICING.map(([p, n, i, o, cr, cw, cw1h, fi, fo]) => ({
|
||||
model_pattern: p,
|
||||
display_name: n,
|
||||
input_per_mtok: i,
|
||||
output_per_mtok: o,
|
||||
cache_read_per_mtok: cr,
|
||||
cache_write_per_mtok: cw,
|
||||
cache_write_1h_per_mtok: cw1h,
|
||||
fast_input_per_mtok: fi,
|
||||
fast_output_per_mtok: fo,
|
||||
}));
|
||||
// A real session: Claude Code's /cost reported $2.72 for exactly these
|
||||
// counts (all cache writes at the 1h tier); Opus 5's rates gave $4.86.
|
||||
const r = calculateCost(
|
||||
[
|
||||
bucket({
|
||||
model: "claude-opus-5-5",
|
||||
input_tokens: 102,
|
||||
output_tokens: 25_408,
|
||||
cache_read_tokens: 5_866_977,
|
||||
cache_write_tokens: 129_432,
|
||||
cache_write_1h_tokens: 129_432,
|
||||
}),
|
||||
],
|
||||
rules
|
||||
);
|
||||
assert.equal(r.total_cost.toFixed(2), "2.72");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Tests for the automatic pricing sync: a model seen in token_usage with
|
||||
* no row of its own gets one from the price list, an edited row is never
|
||||
* overwritten, and a bad list or a failed fetch changes nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
process.env.DASHBOARD_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`pricing-sync-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { db } = require("../db");
|
||||
const { syncPricing, familyOf, displayName, _resetCache } = require("../lib/pricing-sync");
|
||||
|
||||
// The real LiteLLM entry for Opus 5.5, trimmed.
|
||||
const LIST = {
|
||||
"claude-opus-9-9": {
|
||||
litellm_provider: "anthropic",
|
||||
input_cost_per_token: 0.000004,
|
||||
output_cost_per_token: 0.00002,
|
||||
cache_read_input_token_cost: 2e-7,
|
||||
cache_creation_input_token_cost: 0.000005,
|
||||
cache_creation_input_token_cost_above_1hr: 0.000008,
|
||||
supports_fast_mode: true,
|
||||
provider_specific_entry: { fast: 2 },
|
||||
},
|
||||
};
|
||||
|
||||
function useModel(model) {
|
||||
db.prepare("INSERT OR IGNORE INTO sessions (id, status, started_at) VALUES (?, 'active', ?)").run(
|
||||
`s-${model}`,
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.prepare("INSERT INTO token_usage (session_id, model) VALUES (?, ?)").run(`s-${model}`, model);
|
||||
}
|
||||
const row = (p) => db.prepare("SELECT * FROM model_pricing WHERE model_pattern = ?").get(p);
|
||||
|
||||
beforeEach(() => {
|
||||
_resetCache();
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%'").run();
|
||||
});
|
||||
|
||||
describe("pricing sync", () => {
|
||||
it("reduces an id to its family and names it", () => {
|
||||
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
|
||||
assert.equal(familyOf("gpt-4"), null);
|
||||
assert.equal(familyOf("claude-x%"), null);
|
||||
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
|
||||
});
|
||||
|
||||
it("adds a row for a used model that only a broader pattern matched", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9%', 'Broad', 5)"
|
||||
).run();
|
||||
useModel("claude-opus-9-9-20260101");
|
||||
const added = await syncPricing(db, { fetchList: async () => LIST });
|
||||
assert.deepEqual(added, ["claude-opus-9-9%"]);
|
||||
const r = row("claude-opus-9-9%");
|
||||
assert.equal(r.display_name, "Claude Opus 9.9");
|
||||
assert.deepEqual([r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok], [4, 20, 0.2]);
|
||||
assert.deepEqual([r.cache_write_per_mtok, r.cache_write_1h_per_mtok], [5, 8]);
|
||||
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [8, 40]);
|
||||
});
|
||||
|
||||
it("never overwrites a row that exists, and fetches nothing when none is missing", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9-9%', 'Mine', 1)"
|
||||
).run();
|
||||
useModel("claude-opus-9-9");
|
||||
let fetched = false;
|
||||
await syncPricing(db, {
|
||||
fetchList: async () => {
|
||||
fetched = true;
|
||||
return LIST;
|
||||
},
|
||||
});
|
||||
assert.equal(fetched, false);
|
||||
assert.equal(row("claude-opus-9-9%").input_per_mtok, 1);
|
||||
});
|
||||
|
||||
it("skips absurd rates and survives a failed fetch", async () => {
|
||||
useModel("claude-opus-9-9");
|
||||
const bad = { "claude-opus-9-9": { ...LIST["claude-opus-9-9"], input_cost_per_token: 5 } };
|
||||
assert.deepEqual(await syncPricing(db, { fetchList: async () => bad }), []);
|
||||
_resetCache();
|
||||
const failed = await syncPricing(db, {
|
||||
fetchList: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(row("claude-opus-9-9%"), undefined);
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,9 @@ const RULES = [
|
||||
// a Gitea /raw/branch/<ref>/ URL instead.
|
||||
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
|
||||
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
|
||||
skip: [],
|
||||
// A third-party project that really is on GitHub: the public model price
|
||||
// list server/lib/pricing-sync.js reads.
|
||||
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
|
||||
},
|
||||
{
|
||||
name: "GitHub Pages host for a Smartgift site",
|
||||
|
||||
@@ -26,7 +26,11 @@ function fetchJson(p, opts = {}) {
|
||||
const url = new URL(p, BASE);
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
if (opts.raw) {
|
||||
body = opts.raw.buf;
|
||||
headers["Content-Type"] = opts.raw.type;
|
||||
headers["Content-Length"] = body.length;
|
||||
} else if (opts.body !== undefined) {
|
||||
body = Buffer.from(JSON.stringify(opts.body));
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = body.length;
|
||||
@@ -203,3 +207,45 @@ describe("/api/run", () => {
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/run/drop-image", () => {
|
||||
let srv;
|
||||
before(async () => {
|
||||
srv = http.createServer(createApp());
|
||||
await new Promise((r) => srv.listen(0, r));
|
||||
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||
});
|
||||
after(() => new Promise((r) => srv.close(r)));
|
||||
|
||||
const PNG = Buffer.from("89504e470d0a1a0a0000", "hex");
|
||||
|
||||
it("saves the image under a random name, owner-only, and returns its path", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/);
|
||||
assert.ok(r.body.path.startsWith(os.tmpdir()));
|
||||
assert.deepEqual(fs.readFileSync(r.body.path), PNG);
|
||||
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
|
||||
fs.unlinkSync(r.body.path);
|
||||
});
|
||||
|
||||
it("refuses anything that is not one of the image types", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "text/x-shellscript", buf: Buffer.from("rm -rf ~") },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin browser request", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
headers: { Origin: "https://evil.example" },
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -834,6 +834,9 @@ const DEFAULT_PRICING = [
|
||||
["claude-fable-5%", "Claude Fable 5", 10, 50, 1, 12.5, 20, 0, 0],
|
||||
["claude-mythos-5%", "Claude Mythos 5", 10, 50, 1, 12.5, 20, 0, 0],
|
||||
// Opus family (fast mode available on 5 / 4.6 / 4.7 / 4.8)
|
||||
// 5.5 is cheaper than 5 and "claude-opus-5%" would swallow it — own row
|
||||
// (longest pattern wins in calculateCost).
|
||||
["claude-opus-5-5%", "Claude Opus 5.5", 4, 20, 0.2, 5, 8, 8, 40],
|
||||
["claude-opus-5%", "Claude Opus 5", 5, 25, 0.5, 6.25, 10, 10, 50],
|
||||
["claude-opus-4-8%", "Claude Opus 4.8", 5, 25, 0.5, 6.25, 10, 10, 50],
|
||||
["claude-opus-4-7%", "Claude Opus 4.7", 5, 25, 0.5, 6.25, 10, 30, 150],
|
||||
|
||||
@@ -325,6 +325,9 @@ function startBackgroundServices() {
|
||||
// One-time legacy-session backfill (a no-op once its marker file exists).
|
||||
autoImportLegacySessions();
|
||||
|
||||
// Rates for models seen in token_usage with no pricing row of their own.
|
||||
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
||||
|
||||
// A provisioning job belongs to the previous Node process and cannot survive
|
||||
// a restart. Recover those rows now so the dashboard never shows a permanent
|
||||
// provisioning spinner for work that was interrupted by the restart.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file Automatic pricing for Claude models the dashboard has seen but has no
|
||||
* rate row of its own for. Every model id in `token_usage` is reduced to its
|
||||
* family (a `-YYYYMMDD` snapshot suffix dropped); a family with no
|
||||
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
|
||||
* never updated, so a rate the user edited in Settings always wins. "Its own
|
||||
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
|
||||
* Opus 5 by the broader `claude-opus-5%` pattern.
|
||||
*
|
||||
* The list is fetched only when something is missing, at most once per
|
||||
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const PRICE_LIST_URL =
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
||||
const CHECK_MS = 15 * 60_000;
|
||||
const CACHE_MS = 24 * 60 * 60_000;
|
||||
/** A per-MTok rate above this is a parse error, not a price. */
|
||||
const MAX_PER_MTOK = 1000;
|
||||
|
||||
let cached = null; // { at, list }
|
||||
|
||||
/** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */
|
||||
function familyOf(model) {
|
||||
if (typeof model !== "string") return null;
|
||||
const fam = model.replace(/-\d{8}$/, "");
|
||||
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
||||
}
|
||||
|
||||
/** `claude-opus-5-5` → `Claude Opus 5.5`. */
|
||||
function displayName(fam) {
|
||||
const [, ...parts] = fam.split("-");
|
||||
const words = [];
|
||||
const nums = [];
|
||||
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
|
||||
const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
||||
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
|
||||
}
|
||||
|
||||
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
|
||||
function toRow(fam, e) {
|
||||
if (!e || e.litellm_provider !== "anthropic") return null;
|
||||
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
|
||||
const input = per(e.input_cost_per_token);
|
||||
const output = per(e.output_cost_per_token);
|
||||
// Anthropic's published multipliers stand in for a field the list omits.
|
||||
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
|
||||
const w5m = per(e.cache_creation_input_token_cost ?? e.input_cost_per_token * 1.25);
|
||||
const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2);
|
||||
const rates = [input, output, read, w5m, w1h];
|
||||
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
|
||||
if (input === 0 && output === 0) return null;
|
||||
const fast = e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
||||
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
|
||||
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
||||
const r = (n) => Math.round(n * 1e6) / 1e6;
|
||||
return {
|
||||
pattern: `${fam}%`,
|
||||
name: displayName(fam),
|
||||
input: r(input),
|
||||
output: r(output),
|
||||
read: r(read),
|
||||
w5m: r(w5m),
|
||||
w1h: r(w1h),
|
||||
fastIn: fastOk ? r(input * fast) : 0,
|
||||
fastOut: fastOk ? r(output * fast) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Families used in token_usage that have no `<family>%` row of their own. */
|
||||
function missingFamilies(db) {
|
||||
const have = new Set(
|
||||
db
|
||||
.prepare("SELECT model_pattern FROM model_pricing")
|
||||
.all()
|
||||
.map((r) => r.model_pattern)
|
||||
);
|
||||
const out = new Set();
|
||||
for (const { model } of db.prepare("SELECT DISTINCT model FROM token_usage").all()) {
|
||||
const fam = familyOf(model);
|
||||
if (fam && !have.has(`${fam}%`)) out.add(fam);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
async function defaultFetch() {
|
||||
const res = await fetch(PRICE_LIST_URL, { signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) throw new Error(`price list HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass. Returns the patterns inserted. Never throws: pricing is a display
|
||||
* concern and a failed fetch must not reach the server's startup path.
|
||||
*/
|
||||
async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } = {}) {
|
||||
try {
|
||||
const missing = missingFamilies(db);
|
||||
if (missing.length === 0) return [];
|
||||
if (!cached || now - cached.at > CACHE_MS) cached = { at: now, list: await fetchList() };
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
|
||||
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
|
||||
fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const added = [];
|
||||
for (const fam of missing) {
|
||||
const row = toRow(fam, cached.list?.[fam]);
|
||||
if (!row) continue;
|
||||
const { changes } = insert.run(
|
||||
row.pattern,
|
||||
row.name,
|
||||
row.input,
|
||||
row.output,
|
||||
row.read,
|
||||
row.w5m,
|
||||
row.w1h,
|
||||
row.fastIn,
|
||||
row.fastOut
|
||||
);
|
||||
if (changes) added.push(row.pattern);
|
||||
}
|
||||
if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`);
|
||||
return added;
|
||||
} catch (err) {
|
||||
console.warn("[pricing] sync failed:", err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startPricingSync(db) {
|
||||
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return;
|
||||
void syncPricing(db);
|
||||
setInterval(() => void syncPricing(db), CHECK_MS).unref();
|
||||
}
|
||||
|
||||
function _resetCache() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };
|
||||
+55
-1
@@ -19,8 +19,11 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const express = require("express");
|
||||
const { Router } = express;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const runs = require("../lib/pty-run");
|
||||
const tmux = require("../lib/tmux");
|
||||
@@ -81,6 +84,57 @@ router.get("/", (_req, res) => {
|
||||
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
|
||||
* lane, which is what the Workspace page's per-lane history shows.
|
||||
*/
|
||||
// ── Drag & drop into the terminal ─────────────────────────────────────────
|
||||
// A local terminal answers a dropped image by typing its PATH, which is how
|
||||
// `claude` attaches it. A browser has no path to give, so the image is saved
|
||||
// here — on the machine the pane runs on — and the client pastes this path.
|
||||
// Only the bytes come from the request: the name is random and the type picks
|
||||
// the extension from a fixed map, so a request never names a file.
|
||||
const DROP_TYPES = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const DROP_TTL_MS = 24 * 60 * 60_000;
|
||||
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
|
||||
|
||||
router.post(
|
||||
"/drop-image",
|
||||
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const ext =
|
||||
DROP_TYPES[
|
||||
String(req.headers["content-type"] || "")
|
||||
.split(";")[0]
|
||||
.trim()
|
||||
];
|
||||
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
|
||||
}
|
||||
try {
|
||||
const dir = dropDir();
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, name);
|
||||
try {
|
||||
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}.${ext}`);
|
||||
fs.writeFileSync(file, req.body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
let dr = null;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user