feat(web,server,core): attach files to a message from the composer (#121)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Attachment notice for a message's uploaded files: the `[attached file: <path>]` lines the
|
||||
* server appends aren't shown verbatim, they collapse into a single line reading
|
||||
* "Attached files: a.pdf, b.csv" (paperclip icon + static text, no navigation — the files live
|
||||
* in the session scratchpad and the model opens them by path); the body text around them is
|
||||
* rendered as usual by the caller. Same shape as SkillsBanner, so the two notices a message
|
||||
* can carry read as one family.
|
||||
*/
|
||||
import { S } from "../../lib/strings";
|
||||
import { attachmentFileName } from "../../lib/attachments";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
|
||||
/** Paperclip glyph (24×24 line path), shared with the composer's file-attachment entry. */
|
||||
export const PAPERCLIP_ICON =
|
||||
"M21.4 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.2-9.19a4 4 0 0 1 5.65 5.66l-9.19 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48";
|
||||
|
||||
export function AttachedFilesBanner({ files }: { files: string[] }) {
|
||||
const label = S.chat.attachedFilesBanner(files.map(attachmentFileName));
|
||||
return (
|
||||
// max-w-full + truncate, the composer chip's rule (truncate max-w-56) applied to a notice
|
||||
// that has no fixed width of its own: several long names would otherwise wrap the banner
|
||||
// into a paragraph-tall block above the message. The full list stays reachable as a title.
|
||||
<p
|
||||
title={label}
|
||||
className="anim-msg my-2 flex w-fit max-w-full items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400"
|
||||
>
|
||||
<GlyphIcon d={PAPERCLIP_ICON} className="shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,12 @@
|
||||
* the model already in use clears the staging, and both are exclusive with goal mode); a chip is
|
||||
* removed via backspace at the start of the text or its x button, and both are cached with the
|
||||
* draft so they survive a session switch or reload along with the text they belong to;
|
||||
* The "+" menu carries the input add-ons: image upload, file attachment (any type, several at a
|
||||
* time — they ride the task request as base64 data URLs, and the server writes them into the
|
||||
* session scratchpad and appends an `[attached file: <path>]` line to the message, so the model
|
||||
* opens them by path), and goal mode; selected files show as removable chips above the text
|
||||
* body, next to the image thumbnails, and — like images — an attachments-only message is
|
||||
* sendable with no text at all.
|
||||
* The bottom toolbar provides a searchable multi-select skills dropdown (styled like the model
|
||||
* selector: a top search box filtering by name and localized description, plus a checklist;
|
||||
* clicking a row toggles its selection without closing the menu; the button = book icon + label +
|
||||
@@ -68,7 +74,7 @@ import type {
|
||||
TaskInputPart,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeTokens } from "../../lib/format";
|
||||
import { formatBytes, humanizeTokens } from "../../lib/format";
|
||||
import { resolveContextWindow } from "../../lib/context";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import { agentDisplayName } from "../../state/project";
|
||||
@@ -76,6 +82,7 @@ import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { noAutofill } from "../../components/ui/input";
|
||||
import { toastError } from "../../components/ui/toast";
|
||||
import { SkillIcon } from "../skills/skill-icon-view";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
import { ProviderLogo } from "../../components/ui/provider-logo";
|
||||
@@ -97,6 +104,7 @@ import {
|
||||
skillSlashItems,
|
||||
} from "./skill-use";
|
||||
import { GOAL_ICON, UNLIMITED_BUDGET, parseBudgetInput } from "./goal-use";
|
||||
import { PAPERCLIP_ICON } from "./attached-files-banner";
|
||||
|
||||
const APPROVAL_MODES: ApprovalMode[] = ["always-ask", "read-only", "allow-all", "deny-all"];
|
||||
|
||||
@@ -1078,6 +1086,46 @@ function ContextGauge({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One file attachment staged in the composer. `dataUrl` is the base64 `data:` URL sent as the
|
||||
* task input's `file` part; `name` / `size` only feed the chip (the server decides the name the
|
||||
* file actually gets on disk).
|
||||
*/
|
||||
interface Attachment {
|
||||
name: string;
|
||||
size: number;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
/** Mirrors the server's per-file attachment cap (services/task-attachments.ts), so an oversize pick is refused here instead of costing an upload and a 413. */
|
||||
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Reads one file as a base64 data URL; resolves to null on a read error rather than rejecting, so one unreadable file cannot drop the rest of the batch. */
|
||||
function readDataUrl(file: File): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the draft's attachments to a task input — images first (in pick order), then files.
|
||||
* One place, because every send path submits the same draft: the normal send, the follow-up
|
||||
* queue, the @ handoff and the `/model` switch.
|
||||
*/
|
||||
function appendAttachmentParts(
|
||||
input: TaskInputPart[],
|
||||
images: string[],
|
||||
attachments: Attachment[],
|
||||
): void {
|
||||
for (const url of images) input.push({ type: "image_url", imageUrl: url });
|
||||
for (const file of attachments) {
|
||||
input.push({ type: "file", fileName: file.name, dataUrl: file.dataUrl });
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
status,
|
||||
onSend,
|
||||
@@ -1286,6 +1334,10 @@ export function ChatInput({
|
||||
const textRef = useRef(text);
|
||||
textRef.current = text;
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
// File attachments picked from the "+" menu (any type): held as base64 data URLs, exactly
|
||||
// like images — a draft has no Session yet, so there is nothing to upload them to ahead of
|
||||
// time; they travel with the task request and the server files them into the scratchpad.
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
// Slash token start where Escape closed the menu: it stays shut for that one token.
|
||||
@@ -1347,13 +1399,14 @@ export function ChatInput({
|
||||
canSwitchModel: onSwitchModel !== undefined,
|
||||
sessionBusy: running || compacting,
|
||||
});
|
||||
// Sending is also allowed with only a staged switch chip (/agent or /model) or skills selected
|
||||
// and no text: a handoff's first message may be just a [handoff_from] source block, and the
|
||||
// empty-text fallbacks fill in the rest (S.chat.skillsAutoMessage with skills selected,
|
||||
// S.chat.modelSwitchAutoMessage for a staged model switch — see sendNormal). Goal mode instead
|
||||
// requires a text objective and a parseable budget — and an open editor showing an invalid
|
||||
// draft disables Send outright: combined with the editor refusing to close over an invalid
|
||||
// draft (below), no click sequence can fire a goal with a stale committed budget.
|
||||
// Sending is also allowed with no text at all: attachments (images or files), a staged switch
|
||||
// chip (/agent or /model) and selected skills each carry a message on their own — a handoff's
|
||||
// first message may be just a [handoff_from] source block, and the empty-text fallbacks fill in
|
||||
// the rest (S.chat.skillsAutoMessage with skills selected, S.chat.modelSwitchAutoMessage for a
|
||||
// staged model switch — see sendNormal). Goal mode instead requires a text objective and a
|
||||
// parseable budget — and an open editor showing an invalid draft disables Send outright:
|
||||
// combined with the editor refusing to close over an invalid draft (below), no click sequence
|
||||
// can fire a goal with a stale committed budget.
|
||||
const canSend =
|
||||
!running &&
|
||||
!compacting &&
|
||||
@@ -1362,10 +1415,12 @@ export function ChatInput({
|
||||
(goalOn
|
||||
? text.trim().length > 0 &&
|
||||
images.length === 0 &&
|
||||
attachments.length === 0 &&
|
||||
goalBudget !== null &&
|
||||
!(goalBudgetOpen && goalBudgetDraftInvalid)
|
||||
: text.trim().length > 0 ||
|
||||
images.length > 0 ||
|
||||
attachments.length > 0 ||
|
||||
target !== null ||
|
||||
pendingModel !== null ||
|
||||
selectedSkills.length > 0);
|
||||
@@ -1411,9 +1466,9 @@ export function ChatInput({
|
||||
}, [goalBudgetDraft]);
|
||||
|
||||
/**
|
||||
* Engage/exit goal mode; engaging clears any staged switch chip and the images (genuinely
|
||||
* exclusive: a handoff or a model switch opens another session, and the server rejects
|
||||
* non-text goal input). Selected skills stay — they ride the round-1 message as a
|
||||
* Engage/exit goal mode; engaging clears any staged switch chip and every attachment
|
||||
* (genuinely exclusive: a handoff or a model switch opens another session, and the server
|
||||
* rejects non-text goal input). Selected skills stay — they ride the round-1 message as a
|
||||
* [use_skills] block, like a normal send.
|
||||
*/
|
||||
const toggleGoal = useCallback(
|
||||
@@ -1427,19 +1482,20 @@ export function ChatInput({
|
||||
onHandoffTargetChange?.(null);
|
||||
setPendingModel(null);
|
||||
onPendingModelChange?.(null);
|
||||
// Images can't ride a goal (the server rejects non-text goal input): clear any already
|
||||
// attached, or canSend would stay silently false with the objective looking ready.
|
||||
// Attachments can't ride a goal (the server rejects non-text goal input): clear any
|
||||
// already attached, or canSend would stay silently false with the objective looking ready.
|
||||
setImages([]);
|
||||
setAttachments([]);
|
||||
}
|
||||
},
|
||||
[onHandoffTargetChange, onPendingModelChange],
|
||||
);
|
||||
|
||||
// Mid-run steering: while running, Enter/send queues plain text for the running agent
|
||||
// (delivered between turns as a [user_steering] user message). Text only — images / skills /
|
||||
// a staged switch stay in the draft for a later normal send (a staged /agent or /model chip
|
||||
// also blocks steering: the text belongs to the conversation the switch is about to open, not
|
||||
// to the agent running here).
|
||||
// (delivered between turns as a [user_steering] user message). Text only — attachments /
|
||||
// skills / a staged switch stay in the draft for a later normal send (a staged /agent or
|
||||
// /model chip also blocks steering: the text belongs to the conversation the switch is about
|
||||
// to open, not to the agent running here).
|
||||
// `!goalOn`: with the goal chip engaged the text is an OBJECTIVE — steering it into a run
|
||||
// that happens to be active (e.g. a schedule fired) would silently repurpose it.
|
||||
const canSteer =
|
||||
@@ -1463,8 +1519,8 @@ export function ChatInput({
|
||||
localStorage.setItem(STEER_MODE_KEY, mode);
|
||||
};
|
||||
const followUpMode = steerMode === "followup" && onQueueFollowUp !== undefined;
|
||||
// A follow-up is a full normal message: the whole draft (text / images / skills / a staged
|
||||
// switch) is eligible, same content rule as canSend.
|
||||
// A follow-up is a full normal message: the whole draft (text / attachments / skills / a
|
||||
// staged switch) is eligible, same content rule as canSend.
|
||||
// `stagedRoute !== "blocked"`: a staged model fork is never eligible mid-run — the follow-up
|
||||
// path composes the whole draft and then hands it to onSwitchModel rather than to the queue,
|
||||
// so without this gate Enter would fork off a Trace that is still being written.
|
||||
@@ -1477,6 +1533,7 @@ export function ChatInput({
|
||||
stagedRoute !== "blocked" &&
|
||||
(text.trim().length > 0 ||
|
||||
images.length > 0 ||
|
||||
attachments.length > 0 ||
|
||||
target !== null ||
|
||||
pendingModel !== null ||
|
||||
selectedSkills.length > 0);
|
||||
@@ -1489,6 +1546,7 @@ export function ChatInput({
|
||||
running &&
|
||||
text.trim().length === 0 &&
|
||||
images.length === 0 &&
|
||||
attachments.length === 0 &&
|
||||
target === null &&
|
||||
pendingModel === null &&
|
||||
selectedSkills.length === 0;
|
||||
@@ -1823,11 +1881,11 @@ export function ChatInput({
|
||||
/**
|
||||
* The full normal send path (task / handoff / model switch), also the follow-up queue path
|
||||
* and the fallback target when a steer hits the completion race: assembles the [use_skills]
|
||||
* block, the images and the staged switch from the whole draft; `post` decides where a
|
||||
* message that switches nothing goes (default: onSend; follow-up mode: onQueueFollowUp).
|
||||
* Deliberately not gated on `running` — the caller decides (send() gates the normal path;
|
||||
* the steering fallback calls this directly after the server said 409 not_running, when the
|
||||
* local `status` may still lag behind).
|
||||
* block, the attachments (images and files) and the staged switch from the whole draft; `post`
|
||||
* decides where a message that switches nothing goes (default: onSend; follow-up mode:
|
||||
* onQueueFollowUp). Deliberately not gated on `running` — the caller decides (send() gates the
|
||||
* normal path; the steering fallback calls this directly after the server said 409
|
||||
* not_running, when the local `status` may still lag behind).
|
||||
*/
|
||||
// `post` accepts onSend's goal parameter so onSend can be its default; the follow-up queue
|
||||
// (fewer params) is assignable too. Non-goal calls always pass null.
|
||||
@@ -1840,6 +1898,8 @@ export function ChatInput({
|
||||
// [use_skills] block, exactly like a normal send — the server strips leading marker blocks
|
||||
// when recording the objective, and rounds after the first re-inject the objective alone.
|
||||
if (goalOn) {
|
||||
// Objective only: attachments were already cleared when goal mode engaged (and blocked
|
||||
// from being added since), so there is nothing to carry here.
|
||||
setBusy(true);
|
||||
try {
|
||||
const ok = await onSend([{ type: "text", text: buildSkillsMessage(selectedSkills, t) }], {
|
||||
@@ -1882,7 +1942,7 @@ export function ChatInput({
|
||||
const body = buildSkillsMessage(selectedSkills, bodyText);
|
||||
const input: TaskInputPart[] = [];
|
||||
if (body) input.push({ type: "text", text: body });
|
||||
for (const url of images) input.push({ type: "image_url", imageUrl: url });
|
||||
appendAttachmentParts(input, images, attachments);
|
||||
setBusy(true);
|
||||
try {
|
||||
const ok = target
|
||||
@@ -1893,10 +1953,11 @@ export function ChatInput({
|
||||
input,
|
||||
)
|
||||
: await post(input, null);
|
||||
// Only clear the draft after a successful send: on failure (network / conflict / server error) keep the user's input and images.
|
||||
// Only clear the draft after a successful send: on failure (network / conflict / server error) keep the user's input and attachments.
|
||||
if (ok) {
|
||||
setText("");
|
||||
setImages([]);
|
||||
setAttachments([]);
|
||||
setTarget(null);
|
||||
setPendingModel(null);
|
||||
setSelectedSkills([]);
|
||||
@@ -2023,12 +2084,51 @@ export function ChatInput({
|
||||
if (e.target.files) addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
/**
|
||||
* File attachments (any type, no `accept` filter): read as base64 data URLs, the same
|
||||
* transport images use — a draft has no Session to upload to yet. The name and size come
|
||||
* from the File itself and only feed the chip; the server decides the on-disk name.
|
||||
*
|
||||
* Oversize files are rejected from `File.size` before anything is read, the same way trace
|
||||
* import does it (traces-page.tsx): base64-encoding a rejected file in the tab first would
|
||||
* cost the user a freeze and a 33%-larger upload to earn the same 413.
|
||||
*
|
||||
* The whole batch is read before any of it is staged, so the chips — and therefore the
|
||||
* `[attached file: …]` lines the message ends up with — follow the order the files were
|
||||
* picked in, not the order the reads happened to finish in.
|
||||
*/
|
||||
const addAttachments = (files: Iterable<File>) => {
|
||||
if (goalOn) return; // goal input is text-only, same rule as images
|
||||
const picked: File[] = [];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
toastError(S.chat.attachmentTooLarge(file.name));
|
||||
continue;
|
||||
}
|
||||
picked.push(file);
|
||||
}
|
||||
if (picked.length === 0) return;
|
||||
void Promise.all(picked.map(readDataUrl)).then((urls) => {
|
||||
const staged = picked.flatMap((file, i) =>
|
||||
urls[i] ? [{ name: file.name, size: file.size, dataUrl: urls[i]! }] : [],
|
||||
);
|
||||
if (staged.length > 0) setAttachments((prev) => [...prev, ...staged]);
|
||||
});
|
||||
};
|
||||
|
||||
const onPickAttachments = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) addAttachments(e.target.files);
|
||||
e.target.value = "";
|
||||
};
|
||||
/**
|
||||
* The image picker moved into the "+" menu, so the file input can no longer be a `<label>`
|
||||
* wrapper: the menu unmounts its items on select. It lives outside the menu instead and the
|
||||
* entry clicks it — still inside the click's user-activation window, so the dialog opens.
|
||||
* The file-attachment picker below works the same way.
|
||||
*/
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={anchorRef}>
|
||||
@@ -2124,6 +2224,40 @@ export function ChatInput({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached files, right below the image thumbnails: one removable chip each (name +
|
||||
size), since there is nothing to preview. The name is the picked file's — the server
|
||||
sanitizes it when writing to the scratchpad, and the message's banner then shows the
|
||||
on-disk name. */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((file, i) => (
|
||||
<span
|
||||
key={i}
|
||||
title={file.name}
|
||||
className="anim-pop flex max-w-56 items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 py-1 pl-2 pr-1 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<GlyphIcon
|
||||
d={PAPERCLIP_ICON}
|
||||
size={13}
|
||||
className="shrink-0 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<span className="min-w-0 truncate">{file.name}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${S.chat.removeFile} ${file.name}`}
|
||||
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* When the model doesn't support viewing images directly: images still upload as usual,
|
||||
and on send the server writes them to the session's scratchpad and appends the file
|
||||
path into the message text (the model views them via describe_image). A small note is
|
||||
@@ -2477,11 +2611,22 @@ export function ChatInput({
|
||||
className="hidden"
|
||||
onChange={onPickFiles}
|
||||
/>
|
||||
{/* "+" extension menu, leading the row: input add-ons (image upload, goal mode)
|
||||
plus the input settings footer (mid-run send mode — usable while running, which
|
||||
is exactly when it matters, so the button itself never disables). Image upload
|
||||
lives in here rather than as its own toolbar button: one 8x8 slot instead of
|
||||
two, which is the difference between the phone row scrolling and not. */}
|
||||
{/* The file picker's actual input, same arrangement as the image one above; no
|
||||
`accept` — an attachment can be any type, the model reads it from disk. */}
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
disabled={goalOn}
|
||||
className="hidden"
|
||||
onChange={onPickAttachments}
|
||||
/>
|
||||
{/* "+" extension menu, leading the row: input add-ons (image upload, file
|
||||
attachment, goal mode) plus the input settings footer (mid-run send mode —
|
||||
usable while running, which is exactly when it matters, so the button itself
|
||||
never disables). The uploads live in here rather than as their own toolbar
|
||||
buttons: one 8x8 slot instead of three, which is the difference between the
|
||||
phone row scrolling and not. */}
|
||||
<PlusMenu
|
||||
items={[
|
||||
{
|
||||
@@ -2496,6 +2641,19 @@ export function ChatInput({
|
||||
disabled: goalOn,
|
||||
onSelect: () => imageInputRef.current?.click(),
|
||||
},
|
||||
{
|
||||
key: "file",
|
||||
icon: PAPERCLIP_ICON,
|
||||
label: S.chat.uploadFile,
|
||||
// The description doubles as the explanation of where the file ends up:
|
||||
// it is filed into the session scratchpad and reached by path, never
|
||||
// inlined into the conversation.
|
||||
desc: S.chat.uploadFileDesc,
|
||||
active: attachments.length > 0,
|
||||
// Same rule as images: goal input is text-only.
|
||||
disabled: goalOn,
|
||||
onSelect: () => attachmentInputRef.current?.click(),
|
||||
},
|
||||
{
|
||||
key: "goal",
|
||||
icon: GOAL_ICON,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { S } from "../../lib/strings";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import { formatMessageTime } from "../../lib/format";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { splitImageAttachments } from "../../lib/attachments";
|
||||
import { splitAttachments } from "../../lib/attachments";
|
||||
import type { ChatItem, ReconnectItem } from "../../lib/omni/stream-model";
|
||||
import { Md } from "./md";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
@@ -23,6 +23,7 @@ import { GoalRoundBanner } from "./goal-banner";
|
||||
import { HandoffBanner, ModelSwitchBanner } from "./handoff-banner";
|
||||
import { ScheduledBanner } from "./scheduled-banner";
|
||||
import { SkillsBanner } from "./skills-banner";
|
||||
import { AttachedFilesBanner } from "./attached-files-banner";
|
||||
import {
|
||||
parseHandoffMessage,
|
||||
parseModelSwitchMessage,
|
||||
@@ -194,11 +195,13 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
|
||||
// "using skill" banner, with the body rendered as usual.
|
||||
const afterScheduled = scheduled ? scheduled.rest : afterGoal;
|
||||
const skills = parseSkillsMessage(afterScheduled);
|
||||
// Attachment row restoration: for models that don't support images, input images are
|
||||
// written to disk as a path row; this pulls that out at render time and shows the actual
|
||||
// image. Mirrors the vision-model path (user_text + user_image as separate messages) in
|
||||
// shape: one bubble for the text, one bubble per image, styled the same as user_image.
|
||||
const { text, images } = splitImageAttachments(skills ? skills.rest : afterScheduled);
|
||||
// Attachment row restoration (last in the chain — these lines trail the body rather than
|
||||
// prefixing it): for models that don't support images, input images are written to disk
|
||||
// as a path row; this pulls that out at render time and shows the actual image. Mirrors
|
||||
// the vision-model path (user_text + user_image as separate messages) in shape: one
|
||||
// bubble for the text, one bubble per image, styled the same as user_image. Uploaded
|
||||
// files come out of the same pass and collapse into one banner naming them.
|
||||
const { text, images, files } = splitAttachments(skills ? skills.rest : afterScheduled);
|
||||
// Every goal round reads like a normal user message: the body in a user bubble with
|
||||
// the round notice beneath (the system IS re-sending the user's request each round).
|
||||
if (goalRound) {
|
||||
@@ -213,6 +216,10 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
|
||||
<>
|
||||
{scheduled && <ScheduledBanner origin={scheduled.origin} />}
|
||||
{skills && <SkillsBanner names={skills.skills} />}
|
||||
{/* Files uploaded with this message: named above the bubble, like the other
|
||||
message-level notices — the bytes live in the session scratchpad, the model
|
||||
opens them by path (goal mode never gets here: it rejects non-text input). */}
|
||||
{files.length > 0 && <AttachedFilesBanner files={files} />}
|
||||
{text && (
|
||||
<div className="anim-msg group my-4 flex flex-col items-end">
|
||||
<div className="max-w-[88%] rounded-lg bg-gray-100 px-4 py-2.5 md:max-w-[75%] dark:bg-gray-800">
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
/**
|
||||
* Parses image attachment lines out of user message text (for rendering in
|
||||
* the chat UI).
|
||||
* Parses attachment lines out of user message text (for rendering in the chat UI).
|
||||
*
|
||||
* When the session's model doesn't support images, core writes the input
|
||||
* images to the session scratchpad and appends
|
||||
* "[attached image: <path|URL>]" lines to the user text (see core
|
||||
* session-support). At render time, these lines are extracted and turned
|
||||
* back into images: http(s) URLs are referenced directly; local scratchpad
|
||||
* paths are mapped to the `/api/sessions/<sessionId>/scratchpad/<fileName>`
|
||||
* endpoint; unrecognized lines are left displayed as-is in the text (e.g. a
|
||||
* "could not be saved" note, or a path outside this system).
|
||||
* Two producers append these lines to a user message, both because the bytes cannot
|
||||
* travel in the conversation itself (see core's markers/attachment-lines.ts, which owns
|
||||
* the line format both sides share):
|
||||
* - "[attached image: <path|URL>]" — core, when the session's model doesn't support
|
||||
* images: the input images are written to the session scratchpad and read by path;
|
||||
* - "[attached file: <path>]" — the server, for the composer's file attachments, which
|
||||
* land in the same scratchpad directory.
|
||||
*
|
||||
* At render time these lines are extracted from the body text: images are turned back
|
||||
* into pictures (http(s) URLs are referenced directly; local scratchpad paths are mapped
|
||||
* to the `/api/sessions/<sessionId>/scratchpad/<fileName>` endpoint), files become a
|
||||
* banner listing their names. Both kinds are recognized only when the address is one this
|
||||
* system produced — a scratchpad path (or, for an image, an http(s) URL). Anything else is
|
||||
* left displayed as-is in the text (e.g. a "could not be saved" note, a path outside this
|
||||
* system, or a marker-shaped line a user simply typed).
|
||||
*/
|
||||
import {
|
||||
ATTACHED_FILE_PREFIX,
|
||||
ATTACHED_IMAGE_PREFIX,
|
||||
matchAttachedFileLine,
|
||||
matchAttachedImageLine,
|
||||
} from "@prismshadow/penguin-core/markers";
|
||||
|
||||
export interface ParsedAttachments {
|
||||
/** Body text with restored attachment lines removed (unrecognized lines are kept). */
|
||||
text: string;
|
||||
/** Restored image URLs (in order of appearance; usable directly as img src). */
|
||||
images: string[];
|
||||
/** Absolute paths of attached files (in order of appearance; on the server's filesystem, not fetchable as-is). */
|
||||
files: string[];
|
||||
}
|
||||
|
||||
const ATTACHMENT_LINE = /^\[attached image: (.+)\]$/;
|
||||
/** Local scratchpad path → session file endpoint (Windows separators supported). */
|
||||
const SCRATCHPAD_PATH = /[/\\]scratchpad[/\\]([^/\\]+)[/\\]([A-Za-z0-9._-]+)$/;
|
||||
/**
|
||||
* Local scratchpad path → session file endpoint (Windows separators supported). The file name
|
||||
* is anything but a separator: an attachment keeps the name the user gave it (`报告.pdf`), and
|
||||
* both segments are percent-encoded into the URL below, so restricting the character set here
|
||||
* would only make non-ASCII uploads unreachable.
|
||||
*/
|
||||
const SCRATCHPAD_PATH = /[/\\]scratchpad[/\\]([^/\\]+)[/\\]([^/\\]+)$/;
|
||||
|
||||
/** Resolves a single attachment line's address; returns null if unrecognized (the line is kept in the text). */
|
||||
/** Resolves a single image line's address; returns null if unrecognized (the line is kept in the text). */
|
||||
function resolveAttachment(value: string): string | null {
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
const m = SCRATCHPAD_PATH.exec(value);
|
||||
@@ -32,17 +50,44 @@ function resolveAttachment(value: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Splits attachment lines out of user text into "body text + list of image addresses"; returns the input unchanged if there are no attachment lines. */
|
||||
export function splitImageAttachments(text: string): ParsedAttachments {
|
||||
if (!text.includes("[attached image: ")) return { text, images: [] };
|
||||
/**
|
||||
* Splits attachment lines out of user text into "body text + image addresses + file paths";
|
||||
* returns the input unchanged if there are no attachment lines at all (the trailing-blank-line
|
||||
* cleanup below must not touch an ordinary message).
|
||||
*/
|
||||
export function splitAttachments(text: string): ParsedAttachments {
|
||||
if (!text.includes(ATTACHED_IMAGE_PREFIX) && !text.includes(ATTACHED_FILE_PREFIX)) {
|
||||
return { text, images: [], files: [] };
|
||||
}
|
||||
const kept: string[] = [];
|
||||
const images: string[] = [];
|
||||
const files: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const m = ATTACHMENT_LINE.exec(line.trim());
|
||||
const src = m ? resolveAttachment(m[1]!) : null;
|
||||
if (src) images.push(src);
|
||||
else kept.push(line);
|
||||
const trimmed = line.trim();
|
||||
const imageTarget = matchAttachedImageLine(trimmed);
|
||||
const src = imageTarget !== null ? resolveAttachment(imageTarget) : null;
|
||||
if (src) {
|
||||
images.push(src);
|
||||
continue;
|
||||
}
|
||||
// Gated on the scratchpad shape exactly like an image is, and for the same reason: nothing
|
||||
// stops a person from typing `[attached file: …]` into the composer, and the marker is only
|
||||
// trustworthy where the server wrote it. An ungated file line would let one project member
|
||||
// render arbitrary text inside another member's system-notice chrome — and would read to
|
||||
// the model as a genuine invitation to open whatever path it names.
|
||||
const filePath = matchAttachedFileLine(trimmed);
|
||||
if (filePath !== null && SCRATCHPAD_PATH.test(filePath)) {
|
||||
files.push(filePath);
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
// Attachment lines are appended as a block at the end; clean up extra trailing blank lines after removal.
|
||||
return { text: kept.join("\n").replace(/\n+$/, ""), images };
|
||||
return { text: kept.join("\n").replace(/\n+$/, ""), images, files };
|
||||
}
|
||||
|
||||
/** Display name of an attached file: the last path segment (both separators, since the path comes from the server's filesystem). */
|
||||
export function attachmentFileName(filePath: string): string {
|
||||
const segments = filePath.split(/[/\\]/);
|
||||
return segments[segments.length - 1] || filePath;
|
||||
}
|
||||
|
||||
@@ -792,10 +792,17 @@ When done, open index.html in a browser and self-test once.`,
|
||||
},
|
||||
skillsBanner: (names: string[]): string =>
|
||||
`Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
|
||||
/** Composer "+" extension menu (currently only goal mode; more entries later) and the goal chip. */
|
||||
attachedFilesBanner: (names: string[]): string =>
|
||||
`Attached file${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
|
||||
/** Composer "+" extension menu (image upload, file attachment, goal mode) and the goal chip. */
|
||||
plusMenu: "More input options",
|
||||
uploadImage: "Upload image",
|
||||
uploadImageDesc: "Attach images to this message",
|
||||
uploadFile: "Upload file",
|
||||
uploadFileDesc: "Saved to the session scratchpad; the model reads them by path",
|
||||
removeFile: "Remove file",
|
||||
attachmentTooLarge: (name: string): string =>
|
||||
`${name} exceeds the 10MB limit and was not attached.`,
|
||||
goalMode: "Goal mode",
|
||||
goalModeDesc: "Loop until the goal completes",
|
||||
goalBudgetLabel: "Token budget",
|
||||
@@ -974,6 +981,7 @@ When done, open index.html in a browser and self-test once.`,
|
||||
unknown_skill: "This skill is not in the library.",
|
||||
file_not_found: "This file no longer exists.",
|
||||
file_too_large: "The file is too large.",
|
||||
too_many_files: "Too many files attached to one message.",
|
||||
payload_too_large: "The request is too large.",
|
||||
dir_not_absolute: "The directory must be an absolute path.",
|
||||
not_a_dir: "That path is not a directory.",
|
||||
|
||||
@@ -774,10 +774,17 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
|
||||
archived: (n: number) => `已归档(${n})`,
|
||||
},
|
||||
skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`,
|
||||
/** Composer "+" extension menu (currently only goal mode; more entries later) and the goal chip. */
|
||||
/** Attached-file notice above a user message (file names only; the paths stay in the Trace). */
|
||||
attachedFilesBanner: (names: string[]): string => `附加文件:${names.join("、")}`,
|
||||
/** Composer "+" extension menu (image upload, file attachment, goal mode) and the goal chip. */
|
||||
plusMenu: "更多输入方式",
|
||||
uploadImage: "上传图片",
|
||||
uploadImageDesc: "为本条消息附加图片",
|
||||
uploadFile: "上传文件",
|
||||
uploadFileDesc: "文件存入会话临时目录,模型按路径读取",
|
||||
removeFile: "移除文件",
|
||||
/** Toast for a picked file rejected before reading (the server's per-file cap is 10MB). */
|
||||
attachmentTooLarge: (name: string): string => `${name} 超过 10MB 上限,未添加。`,
|
||||
goalMode: "目标模式",
|
||||
goalModeDesc: "循环运行直至目标完成",
|
||||
goalBudgetLabel: "Token 预算",
|
||||
@@ -958,6 +965,7 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
|
||||
unknown_skill: "该技能不在技能库中。",
|
||||
file_not_found: "该文件已不存在。",
|
||||
file_too_large: "文件过大。",
|
||||
too_many_files: "一条消息附加的文件过多。",
|
||||
payload_too_large: "请求体过大。",
|
||||
dir_not_absolute: "目录必须是绝对路径。",
|
||||
not_a_dir: "该路径不是目录。",
|
||||
|
||||
Reference in New Issue
Block a user