feat(giellie): chat UI, approval card, photo/file composer, sidebar rename
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cce2570dcd
commit
b28d57bfc8
9 changed files with 677 additions and 2 deletions
85
src/app/(app)/giellie/page.tsx
Normal file
85
src/app/(app)/giellie/page.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Bot, Menu } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ChatList } from "@/components/giellie/chat-list";
|
||||
import { MessageList } from "@/components/giellie/message-list";
|
||||
import { ApprovalCard } from "@/components/giellie/approval-card";
|
||||
import { Composer } from "@/components/giellie/composer";
|
||||
import { useGielie } from "@/components/giellie/use-giellie";
|
||||
|
||||
export default function GielliePage() {
|
||||
const g = useGielie();
|
||||
// Per-session only; resets on reload by design. Never covers email sends.
|
||||
const [trust, setTrust] = useState(false);
|
||||
const [showList, setShowList] = useState(false);
|
||||
|
||||
const notReady = g.ready && !g.ready.ready;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[calc(100vh-4rem)] overflow-hidden rounded-lg border bg-background">
|
||||
<aside
|
||||
className={
|
||||
"w-64 shrink-0 border-r bg-muted/30 md:block " +
|
||||
(showList ? "block absolute z-20 h-[calc(100vh-4rem)] bg-background shadow-lg" : "hidden")
|
||||
}
|
||||
>
|
||||
<ChatList
|
||||
chats={g.chats}
|
||||
activeId={g.chat?.id ?? null}
|
||||
onOpen={(id) => {
|
||||
setShowList(false);
|
||||
void g.openChat(id);
|
||||
}}
|
||||
onNew={() => {
|
||||
setShowList(false);
|
||||
g.newChat();
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Chats" onClick={() => setShowList((s) => !s)}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<Bot className="h-5 w-5" />
|
||||
<h1 className="text-base font-semibold font-heading-theme">Giellie Agent</h1>
|
||||
<label className="ml-auto flex items-center gap-2 text-xs text-muted-foreground" title="Auto-approve ordinary changes in this session. Emails always ask.">
|
||||
Trust this chat
|
||||
<Switch checked={trust} onCheckedChange={setTrust} />
|
||||
</label>
|
||||
</header>
|
||||
|
||||
{notReady ? (
|
||||
<div className="m-4 rounded-md border border-amber-400/60 bg-amber-50 p-3 text-sm dark:bg-amber-950/30">
|
||||
{g.ready?.reason}{" "}
|
||||
<Link href="/settings/ai" className="underline">
|
||||
Open AI Settings
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!g.chat || g.chat.messages.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-sm text-muted-foreground">
|
||||
Ask about any order, quote, customer or supplier — or paste a price list, upload a spreadsheet, or take a photo.
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={g.chat.messages} liveText={g.liveText} activity={g.activity} busy={g.busy} />
|
||||
)}
|
||||
{g.error && <p className="px-4 pb-2 text-sm text-red-600">{g.error}</p>}
|
||||
{g.pending && (
|
||||
<div className="px-4 pb-3">
|
||||
<ApprovalCard pending={g.pending} busy={g.busy} onDecide={(approve) => void g.decide(approve, trust)} />
|
||||
</div>
|
||||
)}
|
||||
<Composer disabled={g.busy || !!g.pending} onSend={(text, files) => void g.send(text, files, trust)} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/giellie/approval-card.test.tsx
Normal file
38
src/components/giellie/approval-card.test.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ApprovalCard } from "./approval-card";
|
||||
import type { PendingAction } from "@/lib/giellie-types";
|
||||
|
||||
const pending: PendingAction = {
|
||||
tool_uses: [
|
||||
{ id: "1", name: "orders.update", input: { id: "abc", notes: "rush" }, confirmAlways: false },
|
||||
{ id: "2", name: "quotes.send_email", input: { id: "q1" }, confirmAlways: true },
|
||||
],
|
||||
completed: [],
|
||||
order: ["1", "2"],
|
||||
};
|
||||
|
||||
describe("ApprovalCard", () => {
|
||||
it("lists each action with its arguments and flags sends", () => {
|
||||
render(<ApprovalCard pending={pending} busy={false} onDecide={() => {}} />);
|
||||
expect(screen.getByText("orders.update")).toBeTruthy();
|
||||
expect(screen.getByText(/"notes": "rush"/)).toBeTruthy();
|
||||
expect(screen.getByText("quotes.send_email")).toBeTruthy();
|
||||
expect(screen.getByText(/sends an email/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onDecide(true) on Approve and onDecide(false) on Reject", () => {
|
||||
const onDecide = vi.fn();
|
||||
render(<ApprovalCard pending={pending} busy={false} onDecide={onDecide} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /reject/i }));
|
||||
expect(onDecide).toHaveBeenNthCalledWith(1, true);
|
||||
expect(onDecide).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it("disables both buttons while busy", () => {
|
||||
render(<ApprovalCard pending={pending} busy onDecide={() => {}} />);
|
||||
expect((screen.getByRole("button", { name: /approve/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole("button", { name: /reject/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
46
src/components/giellie/approval-card.tsx
Normal file
46
src/components/giellie/approval-card.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Mail, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PendingAction } from "@/lib/giellie-types";
|
||||
|
||||
interface Props {
|
||||
pending: PendingAction;
|
||||
busy: boolean;
|
||||
onDecide: (approve: boolean) => void;
|
||||
}
|
||||
|
||||
export function ApprovalCard({ pending, busy, onDecide }: Props) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-400/60 bg-amber-50 dark:bg-amber-950/30 p-3 space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
Giellie wants to make {pending.tool_uses.length === 1 ? "this change" : `${pending.tool_uses.length} changes`}:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{pending.tool_uses.map((tu) => (
|
||||
<li key={tu.id} className="rounded-md border bg-background p-2">
|
||||
<div className="flex items-center gap-2 text-sm font-mono font-semibold">
|
||||
<span>{tu.name}</span>
|
||||
{tu.confirmAlways && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[11px] font-sans font-medium text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
<Mail className="h-3 w-3" /> Sends an email
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{JSON.stringify(tu.input, null, 2)}
|
||||
</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => onDecide(true)}>
|
||||
<Check className="mr-1 h-4 w-4" /> Approve
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onDecide(false)}>
|
||||
<X className="mr-1 h-4 w-4" /> Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/giellie/chat-list.tsx
Normal file
41
src/components/giellie/chat-list.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GielieChatSummary } from "@/lib/giellie-types";
|
||||
|
||||
interface Props {
|
||||
chats: GielieChatSummary[];
|
||||
activeId: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
onNew: () => void;
|
||||
}
|
||||
|
||||
export function ChatList({ chats, activeId, onOpen, onNew }: Props) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-3">
|
||||
<Button className="w-full" variant="outline" onClick={onNew}>
|
||||
<Plus className="mr-1 h-4 w-4" /> New chat
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-2">
|
||||
{chats.length === 0 && <p className="px-2 py-4 text-xs text-muted-foreground">No chats yet.</p>}
|
||||
{chats.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(c.id)}
|
||||
className={cn(
|
||||
"w-full truncate rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted",
|
||||
c.id === activeId && "bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
{c.title || "New chat"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/giellie/composer.tsx
Normal file
95
src/components/giellie/composer.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Camera, Paperclip, Send, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { downscaleImage } from "@/lib/giellie-image";
|
||||
|
||||
interface Props {
|
||||
disabled: boolean;
|
||||
onSend: (text: string, files: File[]) => void;
|
||||
}
|
||||
|
||||
export function Composer({ disabled, onSend }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const cameraRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function addFiles(list: FileList | null) {
|
||||
if (!list) return;
|
||||
const prepared = await Promise.all(Array.from(list).map((f) => downscaleImage(f)));
|
||||
setFiles((prev) => [...prev, ...prepared].slice(0, 10));
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (disabled || (!text.trim() && files.length === 0)) return;
|
||||
onSend(text, files);
|
||||
setText("");
|
||||
setFiles([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t bg-background p-3 space-y-2">
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((f, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 rounded-full border bg-muted px-2 py-0.5 text-xs">
|
||||
{f.name}
|
||||
<button type="button" aria-label={`Remove ${f.name}`} onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
accept="image/*,.pdf,.csv,.xlsx,.xls,.docx,.txt"
|
||||
onChange={(e) => {
|
||||
void addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cameraRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={(e) => {
|
||||
void addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="Attach files" onClick={() => fileRef.current?.click()}>
|
||||
<Paperclip className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="Take a photo" onClick={() => cameraRef.current?.click()}>
|
||||
<Camera className="h-5 w-5" />
|
||||
</Button>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Ask Giellie anything, or paste a price list…"
|
||||
className="min-h-[44px] max-h-40 flex-1 resize-none"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" size="icon" aria-label="Send" disabled={disabled} onClick={submit}>
|
||||
<Send className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/components/giellie/message-list.tsx
Normal file
132
src/components/giellie/message-list.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Loader2, Paperclip, Wrench } from "lucide-react";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import type { Block, StoredMessage } from "@/lib/giellie-types";
|
||||
import type { ActivityItem } from "./use-giellie";
|
||||
|
||||
interface Props {
|
||||
messages: StoredMessage[];
|
||||
liveText: string;
|
||||
activity: ActivityItem[];
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
/** Extracted-file text is sent to the model but hidden from the bubble. */
|
||||
const isExtractedFile = (b: Block) =>
|
||||
b.type === "text" && typeof b.text === "string" && b.text.startsWith("--- File: ");
|
||||
|
||||
function ToolLine({
|
||||
name,
|
||||
input,
|
||||
result,
|
||||
isError,
|
||||
running,
|
||||
}: {
|
||||
name: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
running?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<details className="rounded-md border bg-muted/40 px-2 py-1 text-xs">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 text-muted-foreground">
|
||||
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wrench className="h-3 w-3" />}
|
||||
<span className="font-mono">{name}</span>
|
||||
{isError && <span className="text-red-600">failed</span>}
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words">{JSON.stringify(input, null, 2)}</pre>
|
||||
{result !== undefined && (
|
||||
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words border-t pt-1">
|
||||
{result.length > 2000 ? result.slice(0, 2000) + "…" : result}
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({ messages, liveText, activity, busy }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const results = useMemo(() => {
|
||||
const map = new Map<string, Block>();
|
||||
for (const m of messages) for (const b of m.content) if (b.type === "tool_result") map.set(String(b.tool_use_id), b);
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [messages, liveText, activity]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{messages.map((m, i) => {
|
||||
if (m.role === "user") {
|
||||
if (m.content.every((b) => b.type === "tool_result")) return null;
|
||||
return (
|
||||
<div key={i} className="flex justify-end">
|
||||
<div className="max-w-[85%] space-y-1 rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground">
|
||||
{m.content
|
||||
.filter((b) => b.type === "attachment")
|
||||
.map((b, j) => (
|
||||
<div key={j} className="flex items-center gap-1 text-xs opacity-90">
|
||||
<Paperclip className="h-3 w-3" /> {String(b.filename)}
|
||||
</div>
|
||||
))}
|
||||
{m.content
|
||||
.filter((b) => b.type === "text" && !isExtractedFile(b))
|
||||
.map((b, j) => (
|
||||
<p key={j} className="whitespace-pre-wrap">{String(b.text)}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={i} className="max-w-[90%] space-y-2">
|
||||
{m.content.map((b, j) => {
|
||||
if (b.type === "text" && String(b.text).trim()) {
|
||||
return (
|
||||
<div key={j} className="rounded-lg border bg-background px-3 py-2 text-sm">
|
||||
<Markdown>{String(b.text)}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (b.type === "tool_use") {
|
||||
const r = results.get(String(b.id));
|
||||
return (
|
||||
<ToolLine
|
||||
key={j}
|
||||
name={String(b.name).replace(/__/g, ".")}
|
||||
input={b.input}
|
||||
result={r ? String(r.content) : undefined}
|
||||
isError={!!r?.is_error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{activity.map((a) => (
|
||||
<div key={a.id} className="max-w-[90%]">
|
||||
<ToolLine name={a.name} input={a.input} result={a.done ? a.preview : undefined} isError={a.isError} running={!a.done} />
|
||||
</div>
|
||||
))}
|
||||
{liveText && (
|
||||
<div className="max-w-[90%] rounded-lg border bg-background px-3 py-2 text-sm">
|
||||
<Markdown>{liveText}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{busy && !liveText && activity.every((a) => a.done) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Giellie is thinking…
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
src/components/giellie/use-giellie.ts
Normal file
211
src/components/giellie/use-giellie.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import { createEventParser, type GielieEvent } from "@/lib/giellie-sse";
|
||||
import type { GielieChat, GielieChatSummary, PendingAction } from "@/lib/giellie-types";
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
done: boolean;
|
||||
isError: boolean;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getPocketBase().authStore.token;
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export function useGielie() {
|
||||
const [ready, setReady] = useState<{ ready: boolean; reason?: string } | null>(null);
|
||||
const [chats, setChats] = useState<GielieChatSummary[]>([]);
|
||||
const [chat, setChat] = useState<GielieChat | null>(null);
|
||||
const [liveText, setLiveText] = useState("");
|
||||
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const chatIdRef = useRef<string | null>(null);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
const { chats } = await json<{ chats: GielieChatSummary[] }>(
|
||||
await fetch("/api/v1/giellie/chats", { headers: authHeaders() }),
|
||||
);
|
||||
setChats(chats);
|
||||
}, []);
|
||||
|
||||
const openChat = useCallback(async (id: string) => {
|
||||
const { chat } = await json<{ chat: GielieChat }>(
|
||||
await fetch(`/api/v1/giellie/chats/${id}`, { headers: authHeaders() }),
|
||||
);
|
||||
chatIdRef.current = id;
|
||||
setChat(chat);
|
||||
setPending(chat.pending_action);
|
||||
setLiveText("");
|
||||
setActivity([]);
|
||||
}, []);
|
||||
|
||||
const newChat = useCallback(() => {
|
||||
chatIdRef.current = null;
|
||||
setChat(null);
|
||||
setPending(null);
|
||||
setLiveText("");
|
||||
setActivity([]);
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setReady(await json(await fetch("/api/v1/giellie/status", { headers: authHeaders() })));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load Giellie.");
|
||||
}
|
||||
})();
|
||||
}, [refreshList]);
|
||||
|
||||
const apply = useCallback((ev: GielieEvent) => {
|
||||
switch (ev.type) {
|
||||
case "text":
|
||||
setLiveText((t) => t + ev.delta);
|
||||
break;
|
||||
case "tool_start":
|
||||
setActivity((a) => [
|
||||
...a,
|
||||
{ id: ev.id, name: ev.name, input: ev.input, done: false, isError: false, preview: "" },
|
||||
]);
|
||||
break;
|
||||
case "tool_result":
|
||||
setActivity((a) =>
|
||||
a.map((i) => (i.id === ev.id ? { ...i, done: true, isError: ev.isError, preview: ev.preview } : i)),
|
||||
);
|
||||
break;
|
||||
case "approval_required":
|
||||
setPending(ev.pending);
|
||||
break;
|
||||
case "error":
|
||||
setError(ev.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const consume = useCallback(
|
||||
async (res: Response, chatId: string) => {
|
||||
if (!res.ok || !res.body) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const parser = createEventParser();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
for (const ev of parser.push(decoder.decode(value, { stream: true }))) apply(ev);
|
||||
}
|
||||
// The server transcript is the source of truth once the stream ends.
|
||||
await openChat(chatId);
|
||||
await refreshList();
|
||||
},
|
||||
[apply, openChat, refreshList],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, files: File[], trust: boolean) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setLiveText("");
|
||||
setActivity([]);
|
||||
try {
|
||||
let id = chatIdRef.current;
|
||||
if (!id) {
|
||||
const created = await json<{ chat: GielieChat }>(
|
||||
await fetch("/api/v1/giellie/chats", { method: "POST", headers: authHeaders() }),
|
||||
);
|
||||
id = created.chat.id;
|
||||
chatIdRef.current = id;
|
||||
setChat(created.chat);
|
||||
}
|
||||
// Optimistic user bubble until the stream ends and the transcript reloads.
|
||||
setChat((c) =>
|
||||
c
|
||||
? {
|
||||
...c,
|
||||
messages: [
|
||||
...c.messages,
|
||||
{
|
||||
role: "user",
|
||||
ts: new Date().toISOString(),
|
||||
content: [
|
||||
...files.map((f) => ({
|
||||
type: "attachment",
|
||||
filename: f.name,
|
||||
kind: f.type.startsWith("image/") ? "image" : "file",
|
||||
})),
|
||||
{ type: "text", text },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: c,
|
||||
);
|
||||
const fd = new FormData();
|
||||
fd.set("text", text);
|
||||
fd.set("trust", trust ? "1" : "0");
|
||||
files.forEach((f) => fd.append("files", f));
|
||||
await consume(
|
||||
await fetch(`/api/v1/giellie/chats/${id}/messages`, { method: "POST", headers: authHeaders(), body: fd }),
|
||||
id,
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Send failed.");
|
||||
if (chatIdRef.current) await openChat(chatIdRef.current).catch(() => {});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[consume, openChat],
|
||||
);
|
||||
|
||||
const decide = useCallback(
|
||||
async (approve: boolean, trust: boolean) => {
|
||||
const id = chatIdRef.current;
|
||||
if (!id) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setPending(null);
|
||||
setLiveText("");
|
||||
setActivity([]);
|
||||
try {
|
||||
await consume(
|
||||
await fetch(`/api/v1/giellie/chats/${id}/decision`, {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ approve, trust }),
|
||||
}),
|
||||
id,
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Decision failed.");
|
||||
await openChat(id).catch(() => {});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[consume, openChat],
|
||||
);
|
||||
|
||||
return { ready, chats, chat, liveText, activity, pending, busy, error, openChat, newChat, send, decide };
|
||||
}
|
||||
|
|
@ -9,13 +9,13 @@ import {
|
|||
Archive,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Boxes,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
MapPin,
|
||||
|
|
@ -138,7 +138,7 @@ const stockNavItems: NavEntry[] = [
|
|||
// clearer home.
|
||||
const otherItems: NavEntry[] = [
|
||||
{ href: "/repairs", label: "Repairs", icon: Wrench },
|
||||
{ href: "/webview?src=hermes", label: "Giellie", icon: Globe },
|
||||
{ href: "/giellie", label: "Giellie Agent", icon: Bot },
|
||||
// Standard Operating Procedures for the operator roles. Markdown
|
||||
// source of truth lives in docs/sops/ — the /sops route renders
|
||||
// it. See src/lib/sops.ts.
|
||||
|
|
@ -479,6 +479,7 @@ const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [
|
|||
{ prefix: "/sprint", label: "Sprint" },
|
||||
{ prefix: "/stock", label: "Stock" },
|
||||
{ prefix: "/settings", label: "Settings" },
|
||||
{ prefix: "/giellie", label: "Giellie Agent" },
|
||||
{ prefix: "/webview", label: "Webview" },
|
||||
{ prefix: "/dashboard", label: "Dashboard" },
|
||||
];
|
||||
|
|
|
|||
26
src/lib/giellie-image.ts
Normal file
26
src/lib/giellie-image.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Client-side: shrink a phone photo before upload. Camera JPEGs are often
|
||||
* 5–12 MB; the server caps files at 10 MB and the model doesn't need more
|
||||
* than ~1600px to read a price list. Non-images pass through untouched.
|
||||
*/
|
||||
export async function downscaleImage(file: File, maxPx = 1600, quality = 0.85): Promise<File> {
|
||||
if (!file.type.startsWith("image/") || file.type === "image/gif") return file;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const scale = Math.min(1, maxPx / Math.max(bitmap.width, bitmap.height));
|
||||
if (scale === 1 && file.size <= 2 * 1024 * 1024) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", quality));
|
||||
if (!blob) return file;
|
||||
return new File([blob], file.name.replace(/\.[^.]+$/, "") + ".jpg", { type: "image/jpeg" });
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue