attach a PDF and the agent reads it; the microphone button is gone
Two requests, one file apart. ── The microphone ── Removed from the chat composer. The HOOK stays: `useAudioRecording` and `blobToWav` are also the file browser's DictateDialog, which is not what was asked to go. ── PDFs, by not extracting anything ── A PDF entry sat in the attach menu once with no `onSelect` at all — the menu offered it and did nothing, because nothing here could extract PDF text. It is back and real, and it still extracts nothing: the file is uploaded, left on disk, and the agent is given its PATH. Its own `Read` handles PDFs by page, better than any extraction this could have shipped, and it can go back for a section later without paying for it twice. The alternative was base64 in the message. That is megabytes in the turn, re-sent for the rest of the conversation, to produce worse text. ── What this uncovered ── `attachmentIds` has been on this websocket and in `types.ts` for a long time and NOTHING on the server read it. Images work because they travel separately as base64, so the ids were dead on the wire — and a PDF is exactly the case that needs them. They resolve to paths now, appended to the prompt as `Attached files (read from disk)`. Ids arrive in a websocket message and become a filesystem path, so they are checked rather than trusted: only the `<uuid>.<ext>` shape uploads actually generate, and only in the two directories uploads actually write to. Verified — a valid id resolves, an unknown one yields nothing, and `../../../../etc/passwd` and `; rm -rf /` yield nothing. Both upload directories are searched because the first message of a chat carries ids written to `tmp/` (no session yet) and later ones carry ids under the session. PDFs may be 25 MB against an image's 5: nothing carries them into a prompt, so the ceiling is storage rather than context. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. `[open]` For a MEMBER the file lands under `data/<email>/attachments/`, and whether their Linux user can read it depends on the confinement ACLs. Untested — the owner path is what was verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getAttachmentsDir, getTmpAttachmentsDir } from '../../data-path';
|
||||||
|
import { getUserById } from 'officerdb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an attached file ended up, so the agent can be told to read it.
|
||||||
|
*
|
||||||
|
* ── Why a path and not the bytes ──
|
||||||
|
*
|
||||||
|
* An image goes to the model as base64 inside the message. A PDF cannot: it would be megabytes of base64
|
||||||
|
* in the turn, re-sent for the rest of the conversation, to produce text the agent's own tools extract
|
||||||
|
* better. Reading a document is something `Read` already does well — page ranges, and re-reading a
|
||||||
|
* section later without paying for it twice.
|
||||||
|
*
|
||||||
|
* So the file stays where `/upload` put it and the prompt names it. This is a machine where the agent
|
||||||
|
* runs as the person who attached it, so it can simply open it.
|
||||||
|
*
|
||||||
|
* ── The ids are checked, not trusted ──
|
||||||
|
*
|
||||||
|
* An id arrives in a websocket message, and it is used to build a path. `/upload` generates them as
|
||||||
|
* `<uuid>.<ext>`, so anything that is not that shape did not come from us — and a `..` inside one would
|
||||||
|
* name a file outside the attachment directory entirely. Only the two directories uploads actually write
|
||||||
|
* to are searched, and only ids matching the shape uploads actually produce.
|
||||||
|
*/
|
||||||
|
const ATTACHMENT_ID = /^[a-f0-9-]{36}\.[A-Za-z0-9]{1,8}$/;
|
||||||
|
|
||||||
|
export async function attachedFilePaths(
|
||||||
|
userId: number,
|
||||||
|
sessionId: string,
|
||||||
|
attachmentIds: string[] | undefined,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!attachmentIds?.length) return [];
|
||||||
|
|
||||||
|
const user = await getUserById(userId).catch(() => null);
|
||||||
|
if (!user?.email) return [];
|
||||||
|
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const id of attachmentIds) {
|
||||||
|
if (!ATTACHMENT_ID.test(id)) continue;
|
||||||
|
// Uploads before a session exists land in `tmp/`; everything after lands under the session. Both are
|
||||||
|
// checked because a first message carries ids from the first shape and later ones from the second.
|
||||||
|
const candidates = [join(getAttachmentsDir(user.email, sessionId), id), join(getTmpAttachmentsDir(user.email), id)];
|
||||||
|
const found = candidates.find((path) => existsSync(path));
|
||||||
|
if (found) paths.push(found);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
||||||
|
import { attachedFilePaths } from './attached-files';
|
||||||
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
|
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
|
||||||
import { resolveHomeDir } from '@@/user-home';
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
import { claudeLoginState } from '@@/os-user-claude';
|
import { claudeLoginState } from '@@/os-user-claude';
|
||||||
@@ -360,10 +361,29 @@ async function handleChat(
|
|||||||
const sessionId = msg.sessionId || randomUUID();
|
const sessionId = msg.sessionId || randomUUID();
|
||||||
|
|
||||||
// Prepend resume summary to the prompt if present
|
// Prepend resume summary to the prompt if present
|
||||||
const prompt = msg.resumeSummary
|
const withSummary = msg.resumeSummary
|
||||||
? `Here is a summary of a previous conversation to continue from:\n\n${msg.resumeSummary}\n\n---\n\nUser's new message: ${msg.prompt}`
|
? `Here is a summary of a previous conversation to continue from:\n\n${msg.resumeSummary}\n\n---\n\nUser's new message: ${msg.prompt}`
|
||||||
: msg.prompt;
|
: msg.prompt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attached files, named by PATH rather than carried in the message.
|
||||||
|
*
|
||||||
|
* `attachmentIds` has been on this wire and in `types.ts` since before today and NOTHING read it —
|
||||||
|
* images work because they travel separately as base64, so the ids were dead. A PDF cannot travel that
|
||||||
|
* way: it would be megabytes of base64 in every turn of the conversation, re-sent forever.
|
||||||
|
*
|
||||||
|
* So the file stays on disk and the agent is told where it is. Reading a document is something its own
|
||||||
|
* tools already do better than a prompt can — page ranges, extraction, re-reading a section later
|
||||||
|
* without paying for it again — and this is a machine where the agent runs as the person who uploaded
|
||||||
|
* the file.
|
||||||
|
*/
|
||||||
|
const attached = await attachedFilePaths(userId, sessionId, msg.attachmentIds);
|
||||||
|
const prompt = attached.length
|
||||||
|
? `${withSummary}\n\nAttached ${attached.length === 1 ? 'file' : 'files'} (read from disk):\n${attached
|
||||||
|
.map((path) => `- ${path}`)
|
||||||
|
.join('\n')}`
|
||||||
|
: withSummary;
|
||||||
|
|
||||||
// Use provided model, or fall back to user default, or the system default.
|
// Use provided model, or fall back to user default, or the system default.
|
||||||
const model = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
|
const model = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { join } from 'node:path';
|
|||||||
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
|
/** PDFs never enter a prompt whole — the agent reads them from disk — so the ceiling is storage, not context. */
|
||||||
|
const PDF_MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||||
|
|
||||||
export const uploadRouter = createRouter();
|
export const uploadRouter = createRouter();
|
||||||
|
|
||||||
@@ -18,12 +20,21 @@ uploadRouter.post('/', async (ctx) => {
|
|||||||
return ctx.json({ error: 'file is required' }, 400);
|
return ctx.json({ error: 'file is required' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file.type.startsWith('image/')) {
|
// Images and PDFs. The two are handled very differently downstream and it is worth knowing which is
|
||||||
return ctx.json({ error: 'Only image files are allowed' }, 400);
|
// which here: an image travels to the model as base64 in the message, while a PDF is left on disk and
|
||||||
|
// the agent is told where it is, because reading a document is something its own tools already do well
|
||||||
|
// (page ranges, extraction) and a 10 MB base64 blob in a prompt is not.
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
|
||||||
|
if (!isImage && !isPdf) {
|
||||||
|
return ctx.json({ error: 'Only images and PDFs are allowed' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
// A PDF is allowed to be bigger: 5 MB is a generous photo and a small report. The agent reads it from
|
||||||
return ctx.json({ error: 'File exceeds 5 MB limit' }, 400);
|
// disk in page ranges, so size costs storage rather than context.
|
||||||
|
const limit = isPdf ? PDF_MAX_FILE_SIZE : MAX_FILE_SIZE;
|
||||||
|
if (file.size > limit) {
|
||||||
|
return ctx.json({ error: `File exceeds ${Math.round(limit / 1024 / 1024)} MB limit` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -41,10 +52,16 @@ uploadRouter.post('/', async (ctx) => {
|
|||||||
await mkdir(saveDir, { recursive: true });
|
await mkdir(saveDir, { recursive: true });
|
||||||
await Bun.write(join(saveDir, filename), buffer);
|
await Bun.write(join(saveDir, filename), buffer);
|
||||||
|
|
||||||
const base64 = buffer.toString('base64');
|
// Only an image gets a data URL. Building one for a PDF would mean carrying megabytes through the
|
||||||
const dataUrl = `data:${file.type};base64,${base64}`;
|
// browser for a preview nothing renders.
|
||||||
|
const dataUrl = isImage ? `data:${file.type};base64,${buffer.toString('base64')}` : '';
|
||||||
|
|
||||||
return ctx.json({ filename: file.name, dataUrl, attachmentId: filename });
|
return ctx.json({
|
||||||
|
filename: file.name,
|
||||||
|
dataUrl,
|
||||||
|
attachmentId: filename,
|
||||||
|
kind: isPdf ? 'pdf' : 'image',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||||
return ctx.json({ error: message }, 500);
|
return ctx.json({ error: message }, 500);
|
||||||
|
|||||||
@@ -589,6 +589,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
attachments: attachmentManager.attachments,
|
attachments: attachmentManager.attachments,
|
||||||
attachWebpage: attachmentManager.attachWebpage,
|
attachWebpage: attachmentManager.attachWebpage,
|
||||||
attachImage: attachmentManager.attachImage,
|
attachImage: attachmentManager.attachImage,
|
||||||
|
attachFile: attachmentManager.attachFile,
|
||||||
removeAttachment: attachmentManager.removeAttachment,
|
removeAttachment: attachmentManager.removeAttachment,
|
||||||
availableModels,
|
availableModels,
|
||||||
showJumpToBottom,
|
showJumpToBottom,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type AttachButtonProps = {
|
|||||||
* attachment that would be discarded before the model saw it. See `supportsImages` in InputArea.
|
* attachment that would be discarded before the model saw it. See `supportsImages` in InputArea.
|
||||||
*/
|
*/
|
||||||
onAttachImage?: (file: File) => void;
|
onAttachImage?: (file: File) => void;
|
||||||
|
onAttachFile?: (file: File) => void;
|
||||||
onAttachWebpage: () => void;
|
onAttachWebpage: () => void;
|
||||||
/** Text files are inlined into the composer rather than sent as an attachment — see below. */
|
/** Text files are inlined into the composer rather than sent as an attachment — see below. */
|
||||||
onAttachText: (text: string) => void;
|
onAttachText: (text: string) => void;
|
||||||
@@ -29,8 +30,15 @@ const TEXT_ACCEPT =
|
|||||||
|
|
||||||
const NUL = String.fromCharCode(0);
|
const NUL = String.fromCharCode(0);
|
||||||
|
|
||||||
export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, size = 'sm' }: AttachButtonProps) {
|
export function AttachButton({
|
||||||
|
onAttachImage,
|
||||||
|
onAttachFile,
|
||||||
|
onAttachWebpage,
|
||||||
|
onAttachText,
|
||||||
|
size = 'sm',
|
||||||
|
}: AttachButtonProps) {
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||||
const textInputRef = useRef<HTMLInputElement>(null);
|
const textInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const sizeClasses = size === 'md' ? 'h-10 w-10' : 'h-7 w-7 md:h-9 md:w-9';
|
const sizeClasses = size === 'md' ? 'h-10 w-10' : 'h-7 w-7 md:h-9 md:w-9';
|
||||||
@@ -81,9 +89,17 @@ export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, siz
|
|||||||
Image
|
Image
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{/* "PDF" used to sit beside this one. Both were inert — no onSelect at all — so the menu offered
|
{/* PDF sat here once with no `onSelect` at all — the menu offered it and did nothing, because
|
||||||
four things and did two. There is no PDF text extraction anywhere in the platform, client or
|
nothing in the platform could extract PDF text. It is back and real, by not extracting
|
||||||
server, so that entry could not be made honest without building one first. */}
|
anything: the file is uploaded, left on disk, and the agent is given its PATH. Its own
|
||||||
|
`Read` handles PDFs by page, which is better than any extraction this could have shipped —
|
||||||
|
and nothing has to carry megabytes of base64 through every turn of the conversation. */}
|
||||||
|
{onAttachFile && (
|
||||||
|
<DropdownMenuItem className="cursor-pointer" onSelect={() => pdfInputRef.current?.click()}>
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
PDF
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => textInputRef.current?.click()}>
|
<DropdownMenuItem className="cursor-pointer" onSelect={() => textInputRef.current?.click()}>
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
Text File
|
Text File
|
||||||
@@ -105,6 +121,17 @@ export function AttachButton({ onAttachImage, onAttachWebpage, onAttachText, siz
|
|||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={pdfInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(ev) => {
|
||||||
|
const file = ev.target.files?.[0];
|
||||||
|
if (file) onAttachFile?.(file);
|
||||||
|
ev.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
ref={textInputRef}
|
ref={textInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Loader2, Image, Link, X } from 'lucide-react';
|
import { FileText, Image, Link, Loader2, X } from 'lucide-react';
|
||||||
import type { Attachment } from '../types';
|
import type { Attachment } from '../types';
|
||||||
|
|
||||||
type AttachmentListProps = {
|
type AttachmentListProps = {
|
||||||
@@ -22,10 +22,16 @@ export function AttachmentList({ attachments, onRemove }: AttachmentListProps) {
|
|||||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
||||||
) : a.type === 'image' ? (
|
) : a.type === 'image' ? (
|
||||||
<Image className="h-3 w-3 shrink-0" />
|
<Image className="h-3 w-3 shrink-0" />
|
||||||
|
) : a.type === 'file' ? (
|
||||||
|
<FileText className="h-3 w-3 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<Link className="h-3 w-3 shrink-0" />
|
<Link className="h-3 w-3 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{a.type === 'image' ? a.filename : a.loading ? 'Loading...' : a.title}</span>
|
{/* A file has no preview and no title — its name is all there is, and all that is needed:
|
||||||
|
the agent is told where it is, not handed its contents. */}
|
||||||
|
<span className="truncate">
|
||||||
|
{a.type === 'image' || a.type === 'file' ? a.filename : a.loading ? 'Loading...' : a.title}
|
||||||
|
</span>
|
||||||
<button type="button" onClick={() => onRemove(i)} className="shrink-0 hover:text-foreground cursor-pointer">
|
<button type="button" onClick={() => onRemove(i)} className="shrink-0 hover:text-foreground cursor-pointer">
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { DragEvent } from 'react';
|
import type { DragEvent } from 'react';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Loader2, Mic, Send, Square, ListPlus, X } from 'lucide-react';
|
import { Loader2, Send, Square, ListPlus, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||||
import { useAudioRecording } from '../useAudioRecording';
|
|
||||||
import { ModelSelector } from './ModelSelector';
|
import { ModelSelector } from './ModelSelector';
|
||||||
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||||
import { armBackgroundJob } from '../../../background-job-origin';
|
import { armBackgroundJob } from '../../../background-job-origin';
|
||||||
@@ -37,6 +36,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
attachments,
|
attachments,
|
||||||
attachWebpage,
|
attachWebpage,
|
||||||
attachImage,
|
attachImage,
|
||||||
|
attachFile,
|
||||||
removeAttachment,
|
removeAttachment,
|
||||||
appendToInput,
|
appendToInput,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
@@ -48,7 +48,6 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
} = manager;
|
} = manager;
|
||||||
|
|
||||||
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
|
|
||||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||||
const [jobDialogOpen, setJobDialogOpen] = useState(false);
|
const [jobDialogOpen, setJobDialogOpen] = useState(false);
|
||||||
// The panel's own directory, so a background job needs no path typed into it.
|
// The panel's own directory, so a background job needs no path typed into it.
|
||||||
@@ -95,10 +94,10 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
<div className="flex items-end gap-1 md:gap-2">
|
<div className="flex items-end gap-1 md:gap-2">
|
||||||
<AttachButton
|
<AttachButton
|
||||||
onAttachImage={supportsImages ? attachImage : undefined}
|
onAttachImage={supportsImages ? attachImage : undefined}
|
||||||
|
onAttachFile={attachFile}
|
||||||
onAttachWebpage={() => setUrlDialogOpen(true)}
|
onAttachWebpage={() => setUrlDialogOpen(true)}
|
||||||
onAttachText={appendToInput}
|
onAttachText={appendToInput}
|
||||||
/>
|
/>
|
||||||
<MicButton recording={recording} transcribing={transcribing} onToggle={toggleRecording} />
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -289,29 +288,3 @@ const QueuedList = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type MicButtonProps = {
|
|
||||||
recording: boolean;
|
|
||||||
transcribing: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MicButton = ({ recording, transcribing, onToggle }: MicButtonProps) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={transcribing}
|
|
||||||
onClick={onToggle}
|
|
||||||
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{transcribing ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : recording ? (
|
|
||||||
<>
|
|
||||||
<span className="absolute inset-0 rounded-lg animate-ping bg-destructive/30" />
|
|
||||||
<Square className="h-3.5 w-3.5 text-destructive" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Mic className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
export type Attachment =
|
export type Attachment =
|
||||||
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
|
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
|
||||||
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
|
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean }
|
||||||
|
/**
|
||||||
|
* A document left on disk for the agent to read, rather than carried in the message.
|
||||||
|
*
|
||||||
|
* A PDF as base64 would be megabytes in every turn of the conversation, to produce text the agent's own
|
||||||
|
* `Read` extracts better — and by page, which a prompt cannot do.
|
||||||
|
*/
|
||||||
|
| { type: 'file'; filename: string; attachmentId: string; loading?: boolean };
|
||||||
|
|
||||||
export type MessageCost = {
|
export type MessageCost = {
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
|
|||||||
@@ -62,6 +62,33 @@ export function useAttachments(params?: UseAttachmentsParams) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A PDF. Uploaded like an image and then deliberately forgotten by the browser.
|
||||||
|
*
|
||||||
|
* Nothing is kept but the id: there is no preview to render and no bytes to send. The id becomes a path
|
||||||
|
* server-side and the agent reads the file itself.
|
||||||
|
*/
|
||||||
|
const attachFile = async (file: File) => {
|
||||||
|
const idx = attachments.length;
|
||||||
|
setAttachments((prev) => [...prev, { type: 'file', filename: file.name, attachmentId: '', loading: true }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (params?.sessionId) formData.append('sessionId', params.sessionId);
|
||||||
|
formData.append('provider', 'chat');
|
||||||
|
|
||||||
|
const res = await client.post<{ attachmentId: string }>('/upload', formData);
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a, i) => (i === idx ? { ...a, attachmentId: res.attachmentId, loading: false } : a)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
// The server's own words — it is the one that knows the size limit and the accepted types.
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to upload file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeAttachment = (index: number) => {
|
const removeAttachment = (index: number) => {
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
@@ -82,6 +109,9 @@ export function useAttachments(params?: UseAttachmentsParams) {
|
|||||||
} else if (a.type === 'image' && a.dataUrl) {
|
} else if (a.type === 'image' && a.dataUrl) {
|
||||||
prefix = `[Attached image: ${a.filename}]\n\n${prefix}`;
|
prefix = `[Attached image: ${a.filename}]\n\n${prefix}`;
|
||||||
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
||||||
|
} else if (a.type === 'file' && a.attachmentId) {
|
||||||
|
// Named, not carried. The server turns the id into a path and tells the agent to read it.
|
||||||
|
prefix = `[Attached file: ${a.filename}]\n\n${prefix}`;
|
||||||
}
|
}
|
||||||
ids.push(a.attachmentId);
|
ids.push(a.attachmentId);
|
||||||
}
|
}
|
||||||
@@ -93,6 +123,7 @@ export function useAttachments(params?: UseAttachmentsParams) {
|
|||||||
attachments,
|
attachments,
|
||||||
attachWebpage,
|
attachWebpage,
|
||||||
attachImage,
|
attachImage,
|
||||||
|
attachFile,
|
||||||
removeAttachment,
|
removeAttachment,
|
||||||
clearAttachments,
|
clearAttachments,
|
||||||
processAttachments,
|
processAttachments,
|
||||||
|
|||||||
Reference in New Issue
Block a user