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:
Yaowei Zheng
2026-07-29 23:44:10 +08:00
committed by GitHub
parent 37fc715ce9
commit 1d23a7acaf
25 changed files with 1692 additions and 131 deletions
@@ -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>
);
}
+189 -31
View File
@@ -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">
+67 -22
View File
@@ -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;
}
+9 -1
View File
@@ -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.",
+9 -1
View File
@@ -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: "该路径不是目录。",