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,51 @@
/**
* `[attached image: …]` / `[attached file: …]` — the one-line markers that hand a file on
* disk to the model.
*
* Unlike this module's other markers these are not `[tag]…[/tag]` blocks and they are
* **appended after** the user's own text rather than prefixed to it. That placement is
* load-bearing: the origin blocks and `[use_skills]` are all parsed at index 0 of the
* message, so a second leading block would break that chain — an attachment is a trailing
* footnote to the message, not a new frame around it.
*
* Two producers write them, for the same reason (the bytes cannot travel in the message
* itself, so the message carries their path instead):
* - `[attached image: <path|URL>]` — core, when the session model has no image input: the
* input images are written to the session scratchpad and the model reads them by path via
* describe_image (see internal/session-support.ts);
* - `[attached file: <path>]` — the server, for the composer's file attachments: the upload
* lands in the session scratchpad and the model opens it with its ordinary file tools
* (see services/task-attachments.ts).
*
* One consumer reads them: the Web App's message renderer, which pulls the lines back out of
* the body text and shows an image / an attachment banner instead (see lib/attachments.ts).
* Producer and parser share the spellings below so the two can never drift apart.
*/
/** Line prefix of an image attachment; exported so a parser can cheaply pre-test a whole body text. */
export const ATTACHED_IMAGE_PREFIX = "[attached image: ";
/** Line prefix of a file attachment (same purpose as ATTACHED_IMAGE_PREFIX). */
export const ATTACHED_FILE_PREFIX = "[attached file: ";
/** The line naming one input image by absolute path (image-less model) or by http(s) URL (referenced as-is). */
export function attachedImageLine(target: string): string {
return `${ATTACHED_IMAGE_PREFIX}${target}]`;
}
/** The line naming one uploaded file by absolute path. */
export function attachedFileLine(filePath: string): string {
return `${ATTACHED_FILE_PREFIX}${filePath}]`;
}
const ATTACHED_IMAGE_RE = /^\[attached image: (.+)\]$/;
const ATTACHED_FILE_RE = /^\[attached file: (.+)\]$/;
/** Inverse of `attachedImageLine`: the target of a whole line that is one image attachment, else null. */
export function matchAttachedImageLine(line: string): string | null {
return ATTACHED_IMAGE_RE.exec(line)?.[1] ?? null;
}
/** Inverse of `attachedFileLine`: the path of a whole line that is one file attachment, else null. */
export function matchAttachedFileLine(line: string): string | null {
return ATTACHED_FILE_RE.exec(line)?.[1] ?? null;
}
@@ -15,6 +15,11 @@
* - **goal** (`goal-block.ts`): `[goal]`, the goal-mode round protocol block prefixed to
* each round's input by the Session's goal loop (line-anchored close — see the module).
*
* `attachment-lines.ts` is the one exception to the block form: `[attached image: …]` /
* `[attached file: …]` are single lines **appended after** a user message, naming a file the
* model must open by path. They live here for the same reason the blocks do — core, the
* server and the Web renderer all have to spell them identically.
*
* `block.ts` owns the spelling itself — the canonical square form for producers and the
* dual-form (square + legacy angle) matching every parser applies, because markers persist in
* Traces and in each agent's stored compaction prompt. `tags.ts` owns the tag list.
@@ -24,6 +29,7 @@
*/
export * from "./block.js";
export * from "./tags.js";
export * from "./attachment-lines.js";
export * from "./engine-blocks.js";
export * from "./origin-blocks.js";
export * from "./steering.js";
@@ -281,3 +281,25 @@ export function parseModelSwitchMessage(text: string): ModelSwitchOrigin | null
}
return origin.sessionId ? origin : null;
}
// ---------------------------------------------------------------------------
// Shared predicate over the whole-message origin blocks
// ---------------------------------------------------------------------------
/**
* True when `text` is **entirely** one origin block whose parser demands a whole-message match:
* `[handoff_from]` and `[model_switch_from]`, both of which compare the match length against the
* trimmed message. Anything appended after such a block — not just prefixed to it — makes it
* unparseable, and the raw marker then renders verbatim in a user bubble.
*
* Exported for the producers that append to an existing message (see core's
* `appendAttachmentLines`): a message of this shape is a machine-written frame, not user text,
* and must be left alone. Deliberately implemented by running the parsers rather than
* re-testing their patterns, so the predicate cannot drift from what they accept.
*
* `[use_skills]` and `[scheduled_task]` are **not** included: they are prefix blocks followed by
* the message's own body and are parsed at index 0 only, so appending after that body is safe.
*/
export function isWholeOriginBlock(text: string): boolean {
return parseHandoffMessage(text) !== null || parseModelSwitchMessage(text) !== null;
}