mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
feat: attach text files to chat messages, read on demand via file tools (#10215)
* feat: attach text files to chat messages, read on demand via file tools
* fix: resolve name collisions and sync message files with the transcript
* refactor: render message file chips with the shared context badge
* fix: suffix same-name attachments, sync registry on compaction, bound file bytes
* feat: carry message files across compaction, drop them from the roster
* refactor: merge context, dom and file badges into one wrapping row
* fix: dedupe identical attachments and make badge list keys collision-proof
* fix: name carried files inside the collapsed summary instead of badges
* fix: serialize registry reconciliation and correct the attachment budget
* fix: reserve pending bytes so overlapping reads honor the attachment budget
* fix: share attachment byte budget across concurrent composers
The bottom composer and the edit box are both mounted while editing an
earlier message, but each enforced MAX_CONVERSATION_FILE_BYTES against
only its own staged files plus the transcript. Two attaches near the cap
could each pass independently and overflow the persisted transcript.
Each composer now publishes its staged bytes (committed attachments +
in-flight reads) to the manager, keyed per instance, and the attach-time
budget subtracts every other live composer's stage. A message an open
composer is editing is skipped from the transcript sum since that
composer's stage stands in for it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: charge an edited message at its persisted size until the edit commits
An edit is not committed until send, so the edited message's persisted
attachments return if the edit is cancelled. Substituting only the edit
box's (possibly emptied) stage let the always-mounted bottom composer
claim headroom that vanishes on cancel: remove the near-limit files in
the editor, fill the bottom draft, cancel, and the persisted transcript
overflows MAX_CONVERSATION_FILE_BYTES.
attachmentBytesExcluding now charges a message another composer is
editing at max(persisted size, editor stage), so freed space only
becomes available once the edit actually commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: preserve a message file's exact name when a session row clashes on rebuild
syncMessageScoped rebuilds message-scoped rows from the transcript through
collision-suffixing addFiles. Session rows load first (on restore), so one
holding a wanted name pushed the rebuilt message row to a "(2)" suffix while
the persisted prompt still referenced the bare name — get() then resolved the
reference to the session asset and the model read the wrong content.
Free the name from the conflicting session row before the rebuild so the
message row reclaims its exact reference. The rename is in-memory only: it is
deterministic and re-applied on every load, and the session roster is
regenerated live each send, so the session asset stays addressable under the
suffix without a persisted-record update.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: reserve resent files across the edit-resend gap
The edit box unmounts (dropping its staged-byte entry) the instant the user
submits an edit, but restartGeneration then awaits registry sync and beforeSend
before the optimistic bubble lands in the transcript. During that gap the
resent files were reserved nowhere, so the always-mounted bottom composer could
attach into the temporary headroom and the resend would then push the persisted
transcript past MAX_CONVERSATION_FILE_BYTES.
restartGeneration now reserves the resent files' bytes in shared manager state
before the transcript slice; sendRequest releases the reservation once it
installs the bubble (or restores the files to the composer on a pre-install
bail). The reservation bridges the gap so the budget stays honored throughout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: re-target an in-flight index when a session row is renamed on rebuild
#freeNameForMessageRow renames a restored session row so a same-named message
attachment can reclaim its exact name. But the row's #indexFile, started under
the old name during restore, stamps via #patchFile(oldName, file) — after the
rename that no longer matches, leaving the row stuck 'indexing' so read_file
rejects it and search_files excludes it.
Re-kick #indexFile under the new name when the renamed row is still indexing;
the stale completion then no-ops (its name is gone).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: release resend reservation on local-command sends; surface compaction-orphaned files
Two follow-ups to the message-attachment work:
- A resend edited to /clear or /compact runs the local-command path and returns
before installing a bubble, so the #RESEND_KEY reservation set by
restartGeneration was never released and its bytes stayed charged, blocking
later attachments. Release it on every sendRequest path that exits before
install (via #releaseResendReservation).
- Drop-oldest compaction (summary fallback) removes API messages without a
summary, so a folded message's `## ATTACHED FILES` reference no longer reaches
the model even though the file stays readable. The roster omits message-scoped
files, so the model loses awareness of them. orphanedMessageFileNames() finds
message files whose only referencing message went negative-index, and the
roster now advertises them (summary compaction already carries its own).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: key the resend reservation per send so unrelated sends can't release it
The resend reservation used a single shared key, so a normal or concurrent
sendRequest released it at its own install/early-return even though it didn't
own it — dropping an in-flight resend's reservation and letting attachments
staged before the resend bubble lands under-count against the byte cap.
restartGeneration now mints a per-resend token, reserves under it, and threads
it through sendRequest as resendReservationKey; releases act only on that key.
A send with no token (every normal send) releases nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: reserve the whole wanted-name set when freeing session rows on rebuild
Freeing a session row for a message-scoped rebuild suffixed it against current
rows only. With the transcript referencing both notes (2).md and notes.md and a
session row named notes.md, freeing notes.md renamed it onto notes (2).md — also
a wanted reference — so that message row cascaded to notes (3).md while its
persisted reference stayed notes (2).md, and read_file returned the session file.
#uniqueName now accepts a reserved set; the rebuild frees each session clash
clear of the entire wanted-name set, so every message row reclaims its exact
reference regardless of collision order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: orphan summary-carried files when drop-oldest removes the summary
orphanedMessageFileNames classified summary rows as always-live, but a summary
carries its folded files' reference on its own API message. When summary
compaction succeeds and a later summarization fails, drop-oldest can remove that
API message, yet the files stayed off the roster — so the model lost their
reference even though they remained readable.
The summary display row now tracks its API index (slot 0 at creation, re-based by
drop-oldest); a negative index reads as "counterpart gone" and its files move to
the roster, mirroring user-message orphans. The index is used only for orphan
detection, never as a restart target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: reserve outgoing file bytes for normal and queued sends too
The resend reservation covered edit/retry, but a normal or queued send has the
same gap: the composer (or queue) clears its files the instant sendRequest is
called, dropping the staged-byte entry, while sendRequest then awaits
regrantLocked()/refreshFolders() before the bubble lands. With a locked or slow
linked folder the composer stays enabled, so a fresh drop can spend the same
headroom and overflow the 5 MB cap once the first bubble installs.
Generalize the reservation: sendRequest mints a per-send token and reserves the
outgoing files' bytes just before attachment upkeep (reusing restartGeneration's
token when present), and releases it on install or any pre-install exit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: join attachment layers on a stable content-hash id
Sixteen review rounds kept finding the same bug family: a message file's
identity was its display name, joined by hand across the registry, the
transcript, the prompt reference, and the render keys — every same-name
collision or interleaved rebuild made two of those copies disagree.
Give each message attachment a deterministic id, attachedTextFileId(name,
content) — a synchronous pure-JS hash (works on plain-HTTP deployments) whose
exact value is pinned by test, since persisted transcripts reference it. The
prompt and roster list the id, the file tools resolve id-first (bare names
remain a fallback for legacy chats and session links), and pre-id transcripts
hydrate on load by recomputing the same hash — no migration state.
Names become display-only and may collide freely, which deletes the machinery
that defended them: the suffix-readback registration loop, session-row renaming
on rebuild (#freeNameForMessageRow, reserved-set #uniqueName), and the
reconciler's serialization guards (#syncSeq/#syncChain) — syncMessageScoped now
compares ids instead of awaiting blob text, so it is synchronous and cannot
interleave. A same-name clash within one draft gets a composer-local courtesy
rename before the id is minted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: resolve bare names to session rows and scope id searches to one row
A bare name is the roster's namespace: session links are advertised by
filename and have no other handle, so a same-named message attachment (which
is addressed by id) must not shadow them. get() now resolves session rows
first, keeping the message-row name lookup only as the fallback for
transcripts persisted before ids existed.
search_files restricted an id reference by mapping it back to the display
name and letting the worker filter on it — same-named files were then
searched together under one label. The tool now passes the resolved row
itself, so an id-scoped search can only ever hit its own file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: label search hits with resolvable ids, normalize attach batches at commit
An unscoped search_files reported hits by display name only. Names may
collide, so a hit could not be mapped back to the row that produced it —
a follow-up read_file on the bare name could return a different same-named
file. Rows carrying an id are now labeled `name (file id: …)` in hit lines,
so every hit names the reference that resolves to exactly that row.
addTextFiles normalized (deduped, courtesy-renamed) each file against a
snapshot taken during its read loop. Attach batches overlap, so a file
committed by another batch between reads escaped both checks — duplicate or
same-named unsuffixed entries in one message. Normalization now runs in the
single synchronous commit step against the live list (foldIntoDraft), where
nothing can interleave.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: dedupe renamed re-drops in foldIntoDraft, truncate queued file chips
"Same file dropped twice" means same original (name, content), but a
courtesy rename erases the original name — an identical re-drop then missed
the duplicate check and landed as a further-suffixed copy. The dedupe now
also matches entries whose suffix-stripped base name equals the read's name.
Queued file chip labels get min-w-0 so long filenames truncate inside
max-w-36 instead of overflowing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: address cubic review — line counts, chip clicks, reference robustness
Five fixes from the cubic pass:
- The prompt advertised split('\n').length lines, one more than read_file
reports for newline-terminated files — textLineCount now matches the tool's
numbering (0 for empty, no phantom trailing line).
- Clicking a sent message's badge opened edit mode (the wrapper's
click-to-edit), unmounting the preview popover as it opened; the badge row
now keeps clicks to itself.
- resolve() accepts the composite label rosters and search hits print
(`name (file id: x)`) — models echo references verbatim, so the printed
form must resolve.
- fileToAttachedTextFile enforces MAX_TEXT_FILE_BYTES itself (raw size +
decoded byte length), so no ingestion path can persist an oversized
attachment past the composer's pre-check.
- Duplicate detection after a courtesy rename now uses an explicit sourceName
instead of inferring provenance from the display name — a user's real
`report (2).md` is not a rename of `report.md`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: exact names win over label parsing, commit recheck uses decoded bytes
resolve() parsed any `name (file id: x)`-shaped reference as a printed label,
so a session file literally named that way became unreachable by its exact
name (the dead-id fallback resolved the base name instead). Exact id and
exact-name lookups now run before label interpretation.
Attachment admission and the pending reservation use raw File.size, but the
committed charge is the decoded UTF-8 length — malformed input decodes each
invalid byte to a 3-byte replacement character, so a file passing the 8KB
text sniff could inflate past the conversation cap. The synchronous commit
step now re-checks the live budget against decoded sizes
(admitWithinByteBudget) and drops what no longer fits, with the budget toast.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: normalize files folded into the queued message
Repeated submissions during a stream aggregate into one queued message, but
their files were concatenated raw: an identical re-attach duplicated its chip
and ate a slot (possibly displacing a distinct file at the eight-file cap),
and a same-name clash skipped the courtesy rename. The queue now folds new
files through the same commit normalization as the composer — the queued
entry is a message draft like any other.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style: compaction boundary label uses text-normal text-2xs
* fix: fold provenance survives pass-through, dequeued files fold into the draft
foldIntoDraft recorded sourceName only for renames it performed itself, so a
file already courtesy-renamed by the composer lost its provenance when folded
into the queue — a later re-attach of the original escaped dedupe. Folds now
compose: the original source name rides through every fold, and dedupe
matches on it.
dequeueMessage restored queued files into a possibly-populated composer by
raw concatenation; prependText now folds them like every other draft
aggregation (dedupe, courtesy rename) before applying the cap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: one MessageDraft owns the lanes that ship with a send
Review rounds kept finding the same P2 shape: an aggregation point where
files join a draft (composer commit, queue append, dequeue restore) that
forgot one of the draft rules — fold dedupe, courtesy rename, slot caps,
byte admission, lanes moving together. The rules existed only as convention
re-implemented per site.
MessageDraft owns them once: text, pastes, images, and text files live on
one object with addFiles (fold + optional decoded-byte admission + cap),
addImages (cap), prepend (restore-merge), replaceIfEmpty (occupied-guard
restore), and take (all lanes leave together). The composer holds a draft
instead of four state vars, and the queue is a draft behind the existing
queuedMessage/queuedImages/queuedFiles accessors — an aggregation point can
no longer skip a rule, because there is no raw array to concatenate into.
Deliberately not moved: @context and DOM picks (ContextManager owns their
lifecycle), the conversation byte budget's cross-composer ledger (store-side
follow-up), and sendRequest's options shape (it decomposes immediately and
is pinned by the manager test suite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: correct the drop-routing comment, condense the budget doc
* fix: address cubic review — name sanitization, drop hold, merge restores
Four fixes from the cubic pass:
- Attachment display names render into model-facing prompt blocks, and OS
filenames may legally contain control characters — sanitizeAttachmentName
strips them at attach and again at every prompt-render site (legacy names
predate the attach-time pass), so a crafted name cannot inject prompt lines.
- Drop routing awaits handle/entry resolution before it can call
addTextFiles; a send during that window landed the dropped files on the
next message. The drop handler now holds sending (holdSendForIngestion,
taken before the first await) until routing completes.
- Restoring a taken queue after a failed auto-send replaced the queued draft
wholesale, silently losing a follow-up queued during the preflight. Both
#restoreQueue and the unmounted-input requeue now merge via draft.prepend —
the taken entry's text lands above the newer follow-up.
- restartGeneration validated the API restart index only after reserving the
resend bytes and truncating the transcript, so a stale index threw with the
reservation leaked and the display transcript half-mutated. The index is
resolved and validated before anything is touched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: restored drafts keep chronological priority, store names stay resolvable
A failed auto-send's restore folded the taken (older) draft's attachments
AFTER a follow-up queued during preflight, so at the slot caps the older
attachments silently dropped despite the text landing first — and only one
entry's pinned context survived. prepend() now puts the restored lanes ahead
(the cap drops the newest additions) and #restoreQueue unions both pinned
contexts by identity.
Session filenames were sanitized only at prompt render, so an id-less file
whose stored name carries control characters was advertised under a name
that resolve() could not match. Names are now sanitized at every store
row-creation site (attach, folder expansion, refresh, and persisted-row
restore for pre-sanitization records), making the advertised name the stored
name everywhere — render-site sanitization remains as defense in depth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: update ee-repo-ref to aaa6cb89b05b76139252c64f057e53b94d12ac60
This commit updates the EE repository reference after PR #680 was merged in windmill-ee-private.
Previous ee-repo-ref: 4c08634af953db5c1125b1fb03f5af211fe21db3
New ee-repo-ref: aaa6cb89b05b76139252c64f057e53b94d12ac60
Automated by sync-ee-ref workflow.
* chore: repin ee-repo-ref to main's eb3690a34b (aaa6cb89 needs the unmerged AmqpTrigger OSS companion)
* Revert "chore: repin ee-repo-ref to main's eb3690a34b (aaa6cb89 needs the unmerged AmqpTrigger OSS companion)"
This reverts commit a782e6f1c5.
* refactor: one name seam per scope — claim on the store, sanitize in the fold
Name rules (sanitize, uniquify, dedupe, resolve) lived as convention at every
creation site: four sites carried verbatim copies, two skipped uniquify
(restore, file placeholders — legacy names could collapse to one display
name), and the dedupe check compared the raw name against sanitized stored
names, so re-linking a control-char file added a spurious copy.
Store side: #claimName(raw) = sanitize + uniquify is now the only way a
session row gets its display name; addFiles derives the sanitized name once
at the top of the loop, so dedupe, uniqueness, and the stored row all see the
same string, while relPath and folder keep the raw on-disk keys they must
match. Draft side: foldIntoDraft sanitizes its reads itself instead of
assuming the reader did, making the fold self-contained. Folder names — raw
grouping keys by design — are sanitized at their model-facing render sites
(roster folder lines, not-found listing), mirroring rosterLine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: a display-name collision is not a duplicate
Sanitizing names before the dedupe compare made two DISTINCT files whose raw
names sanitize identically look like re-links — the second was silently
discarded instead of claiming a suffixed name like the restore path does.
Dedupe now means "the same file re-linked": matching stats (size + mtime)
plus a matching name, pre-suffix sourceName, or raw path. #claimName records
the pre-suffix name when uniquifying renamed a row, so re-linking a suffixed
file's original still dedupes. A same-named file with different stats links
as its own row rather than being silently swallowed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: the re-link identity is the raw name, persisted with the row
Dedupe still compared sanitized display names, so two distinct raw names
that sanitize identically collapsed whenever their stats also matched — and
the provenance recorded for suffixed rows lived only in memory, so after a
reload re-linking the original behind a suffixed row stacked another copy.
sourceName now records the RAW pre-sanitization name on every session row
(display names lose information twice — sanitize, then suffix), rides the
persisted record, and is re-derived on restore. Dedupe matches stats plus
raw identity (sourceName, or relPath for folder children) and never compares
display names. #claimName returns to a pure name function; restore claims
the display name from the persisted raw identity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
import AttachedFilesBar from './files/AttachedFilesBar.svelte'
|
||||
import QueuedMessageChip from './QueuedMessageChip.svelte'
|
||||
import JobsSegment from './JobsSegment.svelte'
|
||||
import { getModifierKey } from '$lib/utils'
|
||||
@@ -272,8 +273,8 @@
|
||||
|
||||
// File attachment is GLOBAL-mode only.
|
||||
const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled)
|
||||
// Steers the OS file picker toward text + image formats (soft hint; images attach to
|
||||
// the message, other files link as text context after a content sniff).
|
||||
// Steers the OS file picker toward text + image formats (soft hint; both attach
|
||||
// to the message — text files after a content sniff).
|
||||
const TEXT_FILE_ACCEPT =
|
||||
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
|
||||
let fileInputEl = $state<HTMLInputElement | null>(null)
|
||||
@@ -361,16 +362,30 @@
|
||||
e.preventDefault()
|
||||
const dt = e.dataTransfer
|
||||
if (!dt) return
|
||||
// Images attach to the message; other files link as text context. Images are
|
||||
// reserved from dt.files BEFORE any await (a send mid-ingestion would land
|
||||
// them on the next message), and dt.files is the only place a disk-less drag
|
||||
// exists — a cross-tab image resolves every getAsFileSystemHandle() to null.
|
||||
// Images and loose text files attach to the message; folders link as session
|
||||
// assets. Images are reserved from dt.files BEFORE any await (a send
|
||||
// mid-ingestion would land them on the next message), and dt.files is the
|
||||
// only place a disk-less drag exists — a cross-tab image resolves every
|
||||
// getAsFileSystemHandle() to null.
|
||||
const flatFiles = Array.from(dt.files ?? [])
|
||||
const topLevelImages = flatFiles.filter(isImageFile)
|
||||
const imageWork: Promise<unknown>[] = []
|
||||
if (topLevelImages.length > 0) {
|
||||
imageWork.push(aiChatInput?.addImages(topLevelImages) ?? Promise.resolve())
|
||||
}
|
||||
// Text-file routing must await handle/entry resolution before it can call
|
||||
// addTextFiles — hold sending across that window (taken BEFORE the first
|
||||
// await) or a send mid-resolution would land the drop on the next message.
|
||||
const releaseSendHold = aiChatInput?.holdSendForIngestion()
|
||||
try {
|
||||
await routeDroppedTextAndFolders(dt, flatFiles)
|
||||
} finally {
|
||||
releaseSendHold?.()
|
||||
}
|
||||
await Promise.all(imageWork)
|
||||
}
|
||||
|
||||
async function routeDroppedTextAndFolders(dt: DataTransfer, flatFiles: File[]) {
|
||||
if (canUseFsAccess) {
|
||||
// getAsFileSystemHandle calls are kicked off synchronously inside this call.
|
||||
const handles = await handlesFromDataTransfer(dt)
|
||||
@@ -381,9 +396,9 @@
|
||||
handles.length === 0
|
||||
? flatFiles
|
||||
: await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile()))
|
||||
// Files are always snapshotted (handle discarded).
|
||||
// Loose text files attach to the message, like images.
|
||||
const textFiles = looseFiles.filter((f) => !isImageFile(f))
|
||||
if (textFiles.length > 0) await handleAddFiles(textFiles)
|
||||
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
|
||||
// Folders link as a live handle.
|
||||
for (const h of handles.filter(isDirectoryHandle)) {
|
||||
await addDirHandle(h)
|
||||
@@ -395,19 +410,25 @@
|
||||
// (no entry API), fall back to the flat dt.files.
|
||||
const entries = await readDroppedEntries(Array.from(dt.items ?? []))
|
||||
const source: FileToAttach[] = entries.length > 0 ? entries : flatFiles
|
||||
// Only top-level images attach to the message, and those were already
|
||||
// reserved from dt.files before the walk — drop them here so they aren't
|
||||
// re-reported as skipped non-text. Folder-nested images are deliberately
|
||||
// NOT attached (the FSA path never extracts folder contents either); they
|
||||
// ride the text ingestion and are summarized as skipped.
|
||||
const textEntries = source.filter((entry) => {
|
||||
// Top-level files attach to the message (images were already reserved
|
||||
// from dt.files before the walk). Folder children keep riding the
|
||||
// session store as a snapshot — including nested images, which are
|
||||
// deliberately NOT attached (the FSA path never extracts folder
|
||||
// contents either); they are summarized as skipped there.
|
||||
const topLevelText: File[] = []
|
||||
const folderEntries: FileToAttach[] = []
|
||||
for (const entry of source) {
|
||||
const file = entry instanceof File ? entry : entry.file
|
||||
const nested = !(entry instanceof File) && entry.path?.includes('/')
|
||||
return !isImageFile(file) || !!nested
|
||||
})
|
||||
if (textEntries.length > 0) await handleAddFiles(textEntries)
|
||||
const nested = !(entry instanceof File) && !!entry.path?.includes('/')
|
||||
if (nested) {
|
||||
folderEntries.push(entry)
|
||||
} else if (!isImageFile(file)) {
|
||||
topLevelText.push(file)
|
||||
}
|
||||
}
|
||||
if (folderEntries.length > 0) await handleAddFiles(folderEntries)
|
||||
if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText)
|
||||
}
|
||||
await Promise.all(imageWork)
|
||||
}
|
||||
|
||||
async function onFileInputChange(e: Event) {
|
||||
@@ -418,7 +439,7 @@
|
||||
const textFiles = picked.filter((f) => !isImageFile(f))
|
||||
// Reserved before the text work is awaited — see onPanelDrop.
|
||||
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
|
||||
if (textFiles.length > 0) await handleAddFiles(textFiles)
|
||||
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
|
||||
await imageWork
|
||||
}
|
||||
input.value = '' // allow re-selecting the same file
|
||||
@@ -754,10 +775,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<JobsSegment standalone />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Context chips (attached files + selected-context / DOM-selector) render
|
||||
<!-- Message-scoped chips (selected-context / DOM-selector / images) render
|
||||
inside the input box via AIChatInput → ContextTextarea's `leading` snippet;
|
||||
selected context also appears as @mentions in the input (deleting the
|
||||
mention deselects). Hence showContext={false} below. -->
|
||||
mention deselects). Hence showContext={false} below. Session-scoped
|
||||
assets (attached files/folders) render in the footer row instead. -->
|
||||
{#if inputPreface}
|
||||
{@render inputPreface()}
|
||||
{/if}
|
||||
@@ -863,12 +885,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<div class="max-w-64 text-xs">
|
||||
<p class="font-semibold">Attach files or link a folder</p>
|
||||
<p class="mt-1">
|
||||
Text files stay in your browser, and a folder is linked live from disk.
|
||||
The assistant lists, searches, and reads them on demand, so their contents
|
||||
are sent only when it reads one.
|
||||
Files and images attach to your next message. Images are seen directly;
|
||||
file contents stay in your browser and are read on demand.
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
Images are sent with your next message, so the assistant can see them.
|
||||
A linked folder is a session-wide resource: the assistant lists, searches,
|
||||
and reads its files whenever it needs them.
|
||||
</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -876,7 +898,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
<!-- Fallback file picker (used when the File System Access API is unavailable).
|
||||
`accept` only steers toward text; the content sniff in addFiles() is authoritative. -->
|
||||
`accept` only steers the picker; the content sniff at attach is authoritative. -->
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
@@ -968,6 +990,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL}
|
||||
<AttachedFilesBar />
|
||||
{/if}
|
||||
{#if !hideModeSelector}
|
||||
<ChatMode />
|
||||
{/if}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import AppAvailableContextList from './AppAvailableContextList.svelte'
|
||||
import ContextElementBadge from './ContextElementBadge.svelte'
|
||||
import ContextTextarea from './ContextTextarea.svelte'
|
||||
import AttachedFilesBar from './files/AttachedFilesBar.svelte'
|
||||
import autosize from '$lib/autosize'
|
||||
import {
|
||||
contextElementKey,
|
||||
createAttachedFileContextElement,
|
||||
isSameContextElement,
|
||||
type AppDomSelectorElement,
|
||||
type ContextElement
|
||||
@@ -31,6 +31,16 @@
|
||||
} from './imageUtils'
|
||||
import { modelSupportsVision } from '../modelConfig'
|
||||
import { tryGetCurrentModel } from '$lib/aiStore'
|
||||
import { createLongHash } from '$lib/editorLangUtils'
|
||||
import {
|
||||
fileToAttachedTextFile,
|
||||
MAX_ATTACHED_FILES,
|
||||
MAX_CONVERSATION_FILE_BYTES,
|
||||
MAX_TEXT_FILE_BYTES,
|
||||
textByteLength,
|
||||
type AttachedTextFile
|
||||
} from './textFileUtils'
|
||||
import { MessageDraft } from './messageDraft.svelte'
|
||||
import ExpandableImage, {
|
||||
isImageViewerOpen
|
||||
} from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
@@ -46,6 +56,7 @@
|
||||
initialInstructions?: string
|
||||
initialPastes?: PasteAttachment[]
|
||||
initialImages?: AttachedImage[]
|
||||
initialFiles?: AttachedTextFile[]
|
||||
editingMessageIndex?: number | null
|
||||
onEditEnd?: () => void
|
||||
className?: string
|
||||
@@ -76,6 +87,7 @@
|
||||
initialInstructions = '',
|
||||
initialPastes = undefined,
|
||||
initialImages = undefined,
|
||||
initialFiles = undefined,
|
||||
editingMessageIndex = null,
|
||||
onEditEnd = () => {},
|
||||
className = '',
|
||||
@@ -142,16 +154,22 @@
|
||||
|
||||
let contextTextareaComponent: ContextTextarea | undefined = $state()
|
||||
let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state()
|
||||
let instructions = $state(untrack(() => initialInstructions))
|
||||
// The four lanes that ship with the next send — text, collapsed big-paste
|
||||
// blobs, per-message images, per-message text files — owned by one draft so
|
||||
// every aggregation applies the draft rules. The composer keeps only the
|
||||
// async in-flight accounting (pending counters, byte reservations).
|
||||
const draft = new MessageDraft(
|
||||
untrack(() => ({
|
||||
text: initialInstructions,
|
||||
pastes: initialPastes ?? [],
|
||||
images: initialImages ?? [],
|
||||
files: initialFiles ?? []
|
||||
}))
|
||||
)
|
||||
$effect(() => {
|
||||
const text = instructions
|
||||
const text = draft.text
|
||||
untrack(() => onDraftChange?.(text))
|
||||
})
|
||||
// Collapsed big-paste blobs referenced by tokens in `instructions`.
|
||||
let pastes = $state<PasteAttachment[]>(untrack(() => initialPastes ?? []))
|
||||
// Per-message image attachments (drag/drop/paste), GLOBAL mode only. One-shot:
|
||||
// they attach to the next send and clear, unlike the persistent attached-files store.
|
||||
let images = $state<AttachedImage[]>(untrack(() => initialImages ?? []))
|
||||
// Images being decoded right now. Holds off sending so a message can never go
|
||||
// out without an attachment the user already dropped, and reserves cap slots
|
||||
// against a concurrent drop.
|
||||
@@ -171,10 +189,10 @@
|
||||
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
|
||||
return
|
||||
}
|
||||
// Count decodes already in flight: two drops that both read `images.length`
|
||||
// Count decodes already in flight: two drops that both read the image count
|
||||
// before either resolves would each claim the same free slots and overshoot
|
||||
// the cap.
|
||||
const remaining = MAX_ATTACHED_IMAGES - images.length - pendingImages
|
||||
const remaining = MAX_ATTACHED_IMAGES - draft.images.length - pendingImages
|
||||
if (remaining <= 0) {
|
||||
sendUserToast(`You can attach up to ${MAX_ATTACHED_IMAGES} images.`, true)
|
||||
return
|
||||
@@ -210,7 +228,7 @@
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if (added.length > 0) images = [...images, ...added]
|
||||
if (added.length > 0) draft.addImages(added)
|
||||
if (failed > 0) sendUserToast(`Could not attach ${failed} image(s).`, true)
|
||||
} finally {
|
||||
pendingImages -= batch.length
|
||||
@@ -218,7 +236,140 @@
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
images = images.filter((_, i) => i !== index)
|
||||
draft.images = draft.images.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
// Files being read right now — same send-hold/slot-reservation role as pendingImages.
|
||||
let pendingFiles = $state(0)
|
||||
// Drop routing resolves file-system handles/entries asynchronously before it
|
||||
// can call addTextFiles/addImages; a send during that window would land the
|
||||
// dropped files on the NEXT message. Holds block sending (no slot or chip
|
||||
// impact) until the drop handler finishes routing.
|
||||
let ingestionHolds = $state(0)
|
||||
export function holdSendForIngestion(): () => void {
|
||||
ingestionHolds += 1
|
||||
let released = false
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true
|
||||
ingestionHolds -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bytes those in-flight reads have claimed against the conversation budget:
|
||||
// two overlapping drops that both read the budget before either lands would
|
||||
// otherwise each spend the same remaining allowance.
|
||||
let pendingFileBytes = $state(0)
|
||||
|
||||
// Publish this composer's staged bytes (committed attachments + in-flight
|
||||
// reads) to the manager so a concurrently-mounted composer — the edit box
|
||||
// while editing an earlier message — sees them in its own budget check and
|
||||
// the two can't each spend the whole conversation allowance.
|
||||
const composerKey = untrack(() => createLongHash())
|
||||
let stagedBytes = $derived(
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes
|
||||
)
|
||||
$effect(() => {
|
||||
aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
|
||||
})
|
||||
$effect(() => () => aiChatManager.clearComposerStaged(composerKey))
|
||||
|
||||
/** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */
|
||||
export async function addTextFiles(candidates: File[]) {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL) return
|
||||
if (candidates.length === 0) return
|
||||
const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles
|
||||
if (remaining <= 0) {
|
||||
sendUserToast(`You can attach up to ${MAX_ATTACHED_FILES} files.`, true)
|
||||
return
|
||||
}
|
||||
const oversized = candidates.filter((f) => f.size > MAX_TEXT_FILE_BYTES)
|
||||
if (oversized.length > 0) {
|
||||
const mb = Math.round(MAX_TEXT_FILE_BYTES / 1_000_000)
|
||||
sendUserToast(
|
||||
`${oversized.length} file(s) over ${mb}MB were skipped — link their folder to read them on demand.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
const usable = candidates.filter((f) => f.size <= MAX_TEXT_FILE_BYTES)
|
||||
if (usable.length === 0) return
|
||||
let batch = usable.slice(0, remaining)
|
||||
if (batch.length < usable.length) {
|
||||
sendUserToast(
|
||||
`You can attach up to ${MAX_ATTACHED_FILES} files; ${usable.length - batch.length} were skipped.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
// Conversation-level byte budget: transcript + queue + every live
|
||||
// composer's stage (this one and, mid-edit, the other) + this composer's
|
||||
// own pending reads. File content is persisted with every history save, so
|
||||
// an unbounded total would grow the chat record without limit. The
|
||||
// transcript sum skips any message a composer is editing — that composer's
|
||||
// stage stands in for it, so counting both would charge those bytes twice.
|
||||
let budget =
|
||||
MAX_CONVERSATION_FILE_BYTES -
|
||||
aiChatManager.attachmentBytesExcluding(composerKey) -
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
|
||||
pendingFileBytes
|
||||
const withinBudget: File[] = []
|
||||
for (const f of batch) {
|
||||
if (f.size <= budget) {
|
||||
withinBudget.push(f)
|
||||
budget -= f.size
|
||||
}
|
||||
}
|
||||
if (withinBudget.length < batch.length) {
|
||||
const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000)
|
||||
sendUserToast(
|
||||
`${batch.length - withinBudget.length} file(s) skipped — this conversation reached its ${mb}MB attachment budget. Link a folder to read larger sets on demand.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
batch = withinBudget
|
||||
if (batch.length === 0) return
|
||||
pendingFiles += batch.length
|
||||
const reservedBytes = batch.reduce((sum, f) => sum + f.size, 0)
|
||||
pendingFileBytes += reservedBytes
|
||||
try {
|
||||
const reads: { name: string; content: string }[] = []
|
||||
let skipped = 0
|
||||
for (const file of batch) {
|
||||
try {
|
||||
const attached = await fileToAttachedTextFile(file)
|
||||
if (attached) reads.push(attached)
|
||||
else skipped++
|
||||
} catch {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
// Commit through the draft in one synchronous step — fold (dedupe,
|
||||
// courtesy rename) and decoded-byte admission both run against the live
|
||||
// list, so another batch landing between this one's file reads can't be
|
||||
// missed, and malformed input that inflates on decode can't slip past the
|
||||
// raw-size admission above. This batch's own raw reservation is excluded
|
||||
// from the budget — the decoded sizes replace it.
|
||||
const liveBudget =
|
||||
MAX_CONVERSATION_FILE_BYTES -
|
||||
aiChatManager.attachmentBytesExcluding(composerKey) -
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
|
||||
(pendingFileBytes - reservedBytes)
|
||||
const { droppedAtBudget } = draft.addFiles(reads, liveBudget)
|
||||
if (droppedAtBudget > 0) {
|
||||
const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000)
|
||||
sendUserToast(
|
||||
`${droppedAtBudget} file(s) skipped — this conversation reached its ${mb}MB attachment budget. Link a folder to read larger sets on demand.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
if (skipped > 0) sendUserToast(`Skipped ${skipped} file(s) (non-text).`, true)
|
||||
} finally {
|
||||
pendingFiles -= batch.length
|
||||
pendingFileBytes -= reservedBytes
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
draft.files = draft.files.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
// App mode @ mention state
|
||||
@@ -250,9 +401,9 @@
|
||||
* leave duplicate tokens. */
|
||||
export function insertMention(title: string) {
|
||||
const target = `@${title}`
|
||||
if (instructions.split(/\s+/).includes(target)) return
|
||||
const sep = instructions.length === 0 || /\s$/.test(instructions) ? '' : ' '
|
||||
instructions = `${instructions}${sep}${target} `
|
||||
if (draft.text.split(/\s+/).includes(target)) return
|
||||
const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' '
|
||||
draft.text = `${draft.text}${sep}${target} `
|
||||
}
|
||||
|
||||
/** Strip every `@title` token from the textarea — used when the user
|
||||
@@ -268,7 +419,7 @@
|
||||
contextTextareaComponent?.unsyncMention(title)
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`(^|\\s)@${escaped}(\\s|$)`, 'g')
|
||||
instructions = instructions.replace(re, (_m, lead, trail) => {
|
||||
draft.text = draft.text.replace(re, (_m, lead, trail) => {
|
||||
// Boundary on at least one side → drop the mention entirely.
|
||||
if (!lead || !trail) return ''
|
||||
// Middle of text: keep ONE of the bracketing whitespace chars so
|
||||
@@ -296,12 +447,23 @@
|
||||
export function restoreInstructions(
|
||||
value: string,
|
||||
restoredPastes: PasteAttachment[] = [],
|
||||
restoredImages: AttachedImage[] = []
|
||||
restoredImages: AttachedImage[] = [],
|
||||
restoredFiles: AttachedTextFile[] = []
|
||||
): boolean {
|
||||
if (instructions.trim() || images.length > 0 || pendingImages > 0) return false
|
||||
instructions = value
|
||||
pastes = restoredPastes
|
||||
images = restoredImages
|
||||
// Attachments still decoding/reading (or mid-drop-routing) count as
|
||||
// occupancy too — they belong to a draft the user started even though
|
||||
// their lane is still empty.
|
||||
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) return false
|
||||
if (
|
||||
!draft.replaceIfEmpty({
|
||||
text: value,
|
||||
pastes: restoredPastes,
|
||||
images: restoredImages,
|
||||
files: restoredFiles
|
||||
})
|
||||
) {
|
||||
return false
|
||||
}
|
||||
focusInput()
|
||||
return true
|
||||
}
|
||||
@@ -311,24 +473,30 @@
|
||||
* the user typed is lost. Restored images join whatever is already
|
||||
* attached, up to the cap — dropping them would lose the attachment
|
||||
* silently, which is the whole reason the queue carries them. */
|
||||
export function prependText(text: string, restoredImages: AttachedImage[] = []): boolean {
|
||||
// Whether the restored text landed on top of a draft the user was already
|
||||
// writing: both instructions now share one composer, so the caller must keep
|
||||
// both their contexts rather than replacing one with the other.
|
||||
const mergedIntoDraft = !!text && !!instructions.trim()
|
||||
// An image-only restore has empty text; prepending it would only add blank lines.
|
||||
if (text) {
|
||||
instructions = instructions.trim() ? `${text}\n\n${instructions}` : text
|
||||
export function prependText(
|
||||
text: string,
|
||||
restoredImages: AttachedImage[] = [],
|
||||
restoredFiles: AttachedTextFile[] = []
|
||||
): boolean {
|
||||
// mergedIntoDraft: the restored text landed on top of a draft the user was
|
||||
// already writing — both instructions now share one composer, so the caller
|
||||
// must keep both their contexts rather than replacing one with the other.
|
||||
const { mergedIntoDraft, droppedImages, droppedFiles } = draft.prepend({
|
||||
text,
|
||||
images: restoredImages,
|
||||
files: restoredFiles
|
||||
})
|
||||
if (droppedImages > 0) {
|
||||
sendUserToast(
|
||||
`You can attach up to ${MAX_ATTACHED_IMAGES} images; ${droppedImages} restored image(s) were dropped.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
if (restoredImages.length > 0) {
|
||||
const merged = [...images, ...restoredImages]
|
||||
if (merged.length > MAX_ATTACHED_IMAGES) {
|
||||
sendUserToast(
|
||||
`You can attach up to ${MAX_ATTACHED_IMAGES} images; ${merged.length - MAX_ATTACHED_IMAGES} restored image(s) were dropped.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
images = merged.slice(0, MAX_ATTACHED_IMAGES)
|
||||
if (droppedFiles > 0) {
|
||||
sendUserToast(
|
||||
`You can attach up to ${MAX_ATTACHED_FILES} files; ${droppedFiles} restored file(s) were dropped.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
focusInput()
|
||||
return mergedIntoDraft
|
||||
@@ -336,8 +504,8 @@
|
||||
|
||||
/** Insert a plain @filename mention for an attached file (used by the @ menu Files category). */
|
||||
export function insertFileMention(name: string) {
|
||||
const sep = instructions.length === 0 || instructions.endsWith(' ') ? '' : ' '
|
||||
instructions = `${instructions}${sep}${formatMention(name)} `
|
||||
const sep = draft.text.length === 0 || draft.text.endsWith(' ') ? '' : ' '
|
||||
draft.text = `${draft.text}${sep}${formatMention(name)} `
|
||||
focusInput()
|
||||
}
|
||||
|
||||
@@ -429,8 +597,8 @@
|
||||
|
||||
function sendRequest() {
|
||||
// The send button is disabled while decoding, but Enter reaches here directly.
|
||||
// Sending now would drop the in-flight images onto the following message.
|
||||
if (pendingImages > 0) {
|
||||
// Sending now would drop the in-flight attachments onto the following message.
|
||||
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) {
|
||||
return
|
||||
}
|
||||
if (aiChatManager.loading) {
|
||||
@@ -444,17 +612,16 @@
|
||||
// chips picked at press time.
|
||||
if (
|
||||
editingMessageIndex === null &&
|
||||
(instructions.trim() ||
|
||||
images.length > 0 ||
|
||||
(aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
|
||||
(!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
|
||||
) {
|
||||
aiChatManager.queueMessage(expanded(chatDraft(instructions, pastes)), images, [
|
||||
...selectedContext
|
||||
])
|
||||
const sent = draft.take()
|
||||
aiChatManager.queueMessage(
|
||||
expanded(chatDraft(sent.text, sent.pastes)),
|
||||
sent.images,
|
||||
[...selectedContext],
|
||||
sent.files
|
||||
)
|
||||
contextTextareaComponent?.clearForSend()
|
||||
instructions = ''
|
||||
pastes = []
|
||||
images = []
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -462,25 +629,30 @@
|
||||
// In edit mode selectedContext is the edit box's own copy (seeded from the
|
||||
// message's original chips), so send exactly what's shown — the user may
|
||||
// have added or removed chips.
|
||||
const sent = draft.take()
|
||||
aiChatManager.restartGeneration(
|
||||
editingMessageIndex,
|
||||
instructions,
|
||||
pastes,
|
||||
images,
|
||||
selectedContext
|
||||
sent.text,
|
||||
sent.pastes,
|
||||
sent.images,
|
||||
selectedContext,
|
||||
sent.files
|
||||
)
|
||||
onEditEnd()
|
||||
} else {
|
||||
aiChatManager.sendRequest({ instructions, pastes, images })
|
||||
const sent = draft.take()
|
||||
aiChatManager.sendRequest({
|
||||
instructions: sent.text,
|
||||
pastes: sent.pastes,
|
||||
images: sent.images,
|
||||
files: sent.files
|
||||
})
|
||||
// clearForSend() pre-zaps the textarea's mention-sync so the wipe
|
||||
// doesn't drop `selectedContext` before `AIChatManager.beforeSend`
|
||||
// snapshots it. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the
|
||||
// fallback textarea still rely on the plain `instructions = ''`
|
||||
// reset (no `@`-mention state to coordinate).
|
||||
// fallback textarea still rely on the draft reset alone (no
|
||||
// `@`-mention state to coordinate).
|
||||
contextTextareaComponent?.clearForSend()
|
||||
instructions = ''
|
||||
pastes = []
|
||||
images = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +661,7 @@
|
||||
// for the conversation bubble and expands them for the LLM inside the manager.
|
||||
function submitRequest() {
|
||||
if (onSendRequest) {
|
||||
onSendRequest(expanded(chatDraft(instructions, pastes)))
|
||||
onSendRequest(expanded(chatDraft(draft.text, draft.pastes)))
|
||||
} else {
|
||||
sendRequest()
|
||||
}
|
||||
@@ -661,7 +833,7 @@
|
||||
}
|
||||
|
||||
function handleAppInput(_e: Event) {
|
||||
const words = instructions.split(/\s+/)
|
||||
const words = draft.text.split(/\s+/)
|
||||
const lastWord = words[words.length - 1]
|
||||
|
||||
if (
|
||||
@@ -680,9 +852,9 @@
|
||||
function handleAppContextSelection(contextElement: ContextElement) {
|
||||
void addContextToSelection(contextElement)
|
||||
// Update instructions with the selected context title
|
||||
const index = instructions.lastIndexOf('@')
|
||||
const index = draft.text.lastIndexOf('@')
|
||||
if (index !== -1) {
|
||||
instructions = instructions.substring(0, index) + `@${contextElement.title}`
|
||||
draft.text = draft.text.substring(0, index) + `@${contextElement.title}`
|
||||
}
|
||||
showAppContextTooltip = false
|
||||
}
|
||||
@@ -696,7 +868,7 @@
|
||||
|
||||
{#snippet sendStopButton()}
|
||||
{@const isLoading = loading ?? aiChatManager.loading}
|
||||
{@const emptyDraft = instructions.trim().length === 0 && images.length === 0}
|
||||
{@const emptyDraft = draft.isEmpty}
|
||||
<!-- A text-free GLOBAL draft with context chips is a valid turn (Enter
|
||||
already sends it), so the button stays enabled there for pointer/touch
|
||||
parity — mirrors the sendRequest guard. Custom onSendRequest consumers
|
||||
@@ -704,6 +876,8 @@
|
||||
{@const sendDisabled =
|
||||
disabled ||
|
||||
pendingImages > 0 ||
|
||||
pendingFiles > 0 ||
|
||||
ingestionHolds > 0 ||
|
||||
(emptyDraft &&
|
||||
(onSendRequest !== undefined ||
|
||||
aiChatManager.mode !== AIMode.GLOBAL ||
|
||||
@@ -725,45 +899,47 @@
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet contextPickerRow()}
|
||||
{#if selectedContext.length > 0}
|
||||
<!-- One wrapping row for every badge-shaped chip: selected context (or, when the
|
||||
picker row is hidden — showContext=false in GLOBAL/session mode — the raw-app
|
||||
inspector's DOM picks, which always show), then message files. Only image
|
||||
thumbnails get their own row (different height). -->
|
||||
{#snippet badgeRow()}
|
||||
{@const contextChips = showContext ? selectedContext : domSelectorChips}
|
||||
{#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 px-2.5 pt-2">
|
||||
{#each selectedContext as element (contextKey(element))}
|
||||
{#each contextChips as element (contextKey(element))}
|
||||
<ContextElementBadge
|
||||
contextElement={element}
|
||||
deletable
|
||||
onDelete={() => {
|
||||
selectedContext = selectedContext?.filter((c) => !isSameContextElement(c, element))
|
||||
removeMention(element.title)
|
||||
if (showContext) removeMention(element.title)
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- DOM selector chips (raw-app inspector picks) always show — even in GLOBAL/
|
||||
session mode where the general context picker row is hidden (showContext=false). -->
|
||||
{#snippet domSelectorChipRow()}
|
||||
{#if domSelectorChips.length > 0}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 px-2.5 pt-2">
|
||||
{#each domSelectorChips as element (contextKey(element))}
|
||||
{#each draft.files as file, i (i)}
|
||||
<ContextElementBadge
|
||||
contextElement={element}
|
||||
contextElement={createAttachedFileContextElement(file.name, file.content)}
|
||||
deletable
|
||||
onDelete={() => {
|
||||
selectedContext = selectedContext?.filter((c) => !isSameContextElement(c, element))
|
||||
}}
|
||||
onDelete={() => removeFile(i)}
|
||||
/>
|
||||
{/each}
|
||||
{#each { length: pendingFiles } as _, i (i)}
|
||||
<div
|
||||
class="h-6 w-24 rounded-md border bg-surface flex items-center justify-center"
|
||||
title="Reading file..."
|
||||
>
|
||||
<Loader2 size={14} class="animate-spin text-tertiary" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet imageChipsRow()}
|
||||
{#if images.length > 0 || pendingImages > 0}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1.5 mb-1">
|
||||
{#each images as image, i (i)}
|
||||
{#if draft.images.length > 0 || pendingImages > 0}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1.5 px-2.5 pt-2">
|
||||
{#each draft.images as image, i (i)}
|
||||
<div class="relative group">
|
||||
<!-- The chip is a 48px object-cover crop, so the expanded view is the only
|
||||
way to check what was actually attached before sending it. -->
|
||||
@@ -811,10 +987,13 @@
|
||||
<div class="relative">
|
||||
<ContextTextarea
|
||||
bind:this={contextTextareaComponent}
|
||||
bind:value={instructions}
|
||||
bind:pastes
|
||||
bind:value={draft.text}
|
||||
bind:pastes={draft.pastes}
|
||||
onImageFiles={aiChatManager.mode === AIMode.GLOBAL
|
||||
? (files) => void addImages(files)
|
||||
? (pasted) => void addImages(pasted)
|
||||
: undefined}
|
||||
onTextFiles={aiChatManager.mode === AIMode.GLOBAL
|
||||
? (pasted) => void addTextFiles(pasted)
|
||||
: undefined}
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
@@ -833,16 +1012,7 @@
|
||||
{onKeyDown}
|
||||
>
|
||||
{#snippet leading()}
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL}
|
||||
<div class="px-2.5 empty:hidden">
|
||||
<AttachedFilesBar />
|
||||
</div>
|
||||
{/if}
|
||||
{#if showContext}
|
||||
{@render contextPickerRow()}
|
||||
{:else}
|
||||
{@render domSelectorChipRow()}
|
||||
{/if}
|
||||
{@render badgeRow()}
|
||||
{@render imageChipsRow()}
|
||||
{/snippet}
|
||||
</ContextTextarea>
|
||||
@@ -854,12 +1024,12 @@
|
||||
</div>
|
||||
{:else if aiChatManager.mode === AIMode.APP}
|
||||
{#if showContext}
|
||||
{@render contextPickerRow()}
|
||||
{@render badgeRow()}
|
||||
{/if}
|
||||
<div class={twMerge('relative w-full scroll-pb-2', className)}>
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={instructions}
|
||||
bind:value={draft.text}
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
oninput={handleAppInput}
|
||||
onblur={() => {
|
||||
@@ -923,7 +1093,7 @@
|
||||
<div class={twMerge('relative w-full scroll-pb-2 pt-2', className)}>
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={instructions}
|
||||
bind:value={draft.text}
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
onkeydown={(e) => {
|
||||
if (onKeyDown) {
|
||||
|
||||
@@ -54,7 +54,8 @@ import {
|
||||
buildSummaryMessageContent
|
||||
} from './compactionPrompt'
|
||||
import { dfs } from '$lib/components/flows/previousResults'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
||||
import { createLongHash } from '$lib/editorLangUtils'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { maskKey } from '$lib/components/sessions/modifiedItemsMask'
|
||||
import { getStringError } from './utils'
|
||||
@@ -67,6 +68,14 @@ import {
|
||||
stripImagePartsFromMessages
|
||||
} from './imageUtils'
|
||||
import { chatDraft, expanded } from './chatDraft'
|
||||
import { MessageDraft, type DraftSnapshot } from './messageDraft.svelte'
|
||||
import {
|
||||
MAX_ATTACHED_FILES,
|
||||
sanitizeAttachmentName,
|
||||
textByteLength,
|
||||
withAttachedTextFileIds,
|
||||
type AttachedTextFile
|
||||
} from './textFileUtils'
|
||||
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
|
||||
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
import { untrack } from 'svelte'
|
||||
@@ -325,9 +334,12 @@ function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean)
|
||||
return appendWebSearchErrorHint(message, webSearchUnavailable)
|
||||
}
|
||||
|
||||
/** A message queued while a turn streams: text, images and the pinned context
|
||||
* snapshot always move together so a flush can't drop one. */
|
||||
type QueuedEntry = { text: string; images: AttachedImage[]; context: ContextElement[] | undefined }
|
||||
/** A message queued while a turn streams: the draft lanes and the pinned
|
||||
* context snapshot always move together so a flush can't drop one. */
|
||||
type QueuedEntry = {
|
||||
draft: DraftSnapshot
|
||||
context: ContextElement[] | undefined
|
||||
}
|
||||
|
||||
export class AIChatManager {
|
||||
contextManager = new ContextManager()
|
||||
@@ -347,20 +359,29 @@ export class AIChatManager {
|
||||
savedSize = $state<number>(0)
|
||||
instructions = $state<string>('')
|
||||
pendingPrompt = $state<string>('')
|
||||
// Message typed while a turn is streaming. There is only ever one queued
|
||||
// message; pressing Enter again appends another line to it. Auto-sent when
|
||||
// Message queued while a turn is streaming. There is only ever one queued
|
||||
// draft; pressing Enter again appends another line to it. Auto-sent when
|
||||
// the turn finishes (clean completion or user cancel). Ephemeral — never
|
||||
// saved to displayMessages or history.
|
||||
queuedMessage = $state<string>('')
|
||||
// saved to displayMessages or history. Owning it as a MessageDraft means
|
||||
// every aggregation applies the draft rules (fold, caps, lanes move
|
||||
// together) instead of re-implementing them here.
|
||||
#queuedDraft = new MessageDraft()
|
||||
// Context snapshot to send WITH the queued message, when it must stay scoped to
|
||||
// what was selected at queue time (e.g. an inline element prompt submitted mid-
|
||||
// stream) rather than the live selection, which may change before the flush.
|
||||
queuedContext = $state<ContextElement[] | undefined>(undefined)
|
||||
// Images attached to that message. Kept beside the text rather than inside it
|
||||
// because the queued chip renders `queuedMessage` as a plain string. Always
|
||||
// move the two together — #takeQueue/#clearQueue/#restoreQueue exist so no
|
||||
// call site can drop one and auto-send a message the user never wrote.
|
||||
queuedImages = $state<AttachedImage[]>([])
|
||||
get queuedMessage(): string {
|
||||
return this.#queuedDraft.text
|
||||
}
|
||||
set queuedMessage(text: string) {
|
||||
this.#queuedDraft.text = text
|
||||
}
|
||||
get queuedImages(): AttachedImage[] {
|
||||
return this.#queuedDraft.images
|
||||
}
|
||||
get queuedFiles(): AttachedTextFile[] {
|
||||
return this.#queuedDraft.files
|
||||
}
|
||||
// Jobs the chat started that detached into the background (global/sessions
|
||||
// chat only). Rendered in the jobs tray, persisted with the chat, and advanced
|
||||
// by a single background poller. See registerJob / #pollBackgroundJobs.
|
||||
@@ -1084,8 +1105,12 @@ export class AIChatManager {
|
||||
// "counterpart gone": storedImages finds nothing there, and restart maps
|
||||
// it to an empty history (everything before it was dropped too, since
|
||||
// compaction only removes prefixes).
|
||||
// A summary row also carries its API index (for orphan detection) — re-base it
|
||||
// too so it reads "counterpart gone" once drop-oldest removes the summary.
|
||||
this.displayMessages = this.displayMessages.map((m) =>
|
||||
m.role === 'user' ? { ...m, index: m.index - drop } : m
|
||||
m.role === 'user' || (m.role === 'summary' && m.index !== undefined)
|
||||
? { ...m, index: m.index! - drop }
|
||||
: m
|
||||
)
|
||||
return freed
|
||||
}
|
||||
@@ -1131,13 +1156,43 @@ export class AIChatManager {
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail]
|
||||
// Files attached to folded-away messages ride the summary: the transcript
|
||||
// is their durable home, so dropping the referencing message without
|
||||
// carrying them would lose the attachment entirely. Deduped by stable id:
|
||||
// several folded turns can carry the identical file, and the summary must
|
||||
// list/keep it once.
|
||||
const carriedById = new Map<string, AttachedTextFile>()
|
||||
for (const m of this.displayMessages.slice(0, displayKeepFrom)) {
|
||||
if ((m.role === 'user' || m.role === 'summary') && m.files) {
|
||||
for (const f of withAttachedTextFileIds(m.files)) carriedById.set(f.id!, f)
|
||||
}
|
||||
}
|
||||
const carriedFiles = [...carriedById.values()]
|
||||
const filesNote =
|
||||
carriedFiles.length > 0
|
||||
? '\n\nThe user attached these files earlier in this conversation; they are still readable via `read_file` / `search_files` (pass the file id):\n' +
|
||||
carriedFiles
|
||||
.map((f) => `- ${sanitizeAttachmentName(f.name)} (file id: ${f.id})`)
|
||||
.join('\n')
|
||||
: ''
|
||||
|
||||
this.messages = [
|
||||
{ role: 'user', content: buildSummaryMessageContent(formatted) + filesNote },
|
||||
...tail
|
||||
]
|
||||
|
||||
// Replace the summarized display prefix with the boundary marker and
|
||||
// re-base the surviving tail's restart indices (the summary occupies
|
||||
// slot 0, so the tail now starts at slot 1).
|
||||
this.displayMessages = [
|
||||
{ role: 'summary', content: formatted },
|
||||
{
|
||||
role: 'summary',
|
||||
content: formatted,
|
||||
// The summary API message sits at slot 0 of the rewritten history; track
|
||||
// it so a later drop-oldest that removes it can orphan the carried files.
|
||||
index: 0,
|
||||
files: carriedFiles.length > 0 ? carriedFiles : undefined
|
||||
},
|
||||
...this.displayMessages
|
||||
.slice(displayKeepFrom)
|
||||
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
|
||||
@@ -1293,6 +1348,9 @@ export class AIChatManager {
|
||||
)
|
||||
switch (result) {
|
||||
case 'ok':
|
||||
// Reconcile file registrations with the compacted transcript — the
|
||||
// summary message carries the folded-away turns' files forward.
|
||||
this.#syncMessageFiles()
|
||||
await this.historyManager.saveChat(
|
||||
this.displayMessages,
|
||||
this.messages,
|
||||
@@ -1320,8 +1378,9 @@ export class AIChatManager {
|
||||
if ((result === 'ok' || this.wasCancelledByUser()) && this.#hasQueuedMessage()) {
|
||||
const next = this.#takeQueue()
|
||||
const accepted = await this.sendRequest({
|
||||
instructions: next.text,
|
||||
images: next.images,
|
||||
instructions: next.draft.text,
|
||||
images: next.draft.images,
|
||||
files: next.draft.files,
|
||||
contextOverride: next.context,
|
||||
queued: true
|
||||
})
|
||||
@@ -1462,22 +1521,31 @@ export class AIChatManager {
|
||||
* one queued message; pressing Enter again appends the new text as another
|
||||
* line so it all goes out as a single message, and its images accumulate
|
||||
* alongside it. */
|
||||
queueMessage(text: string, images: AttachedImage[] = [], context?: ContextElement[]) {
|
||||
queueMessage(
|
||||
text: string,
|
||||
images: AttachedImage[] = [],
|
||||
context?: ContextElement[],
|
||||
files: AttachedTextFile[] = []
|
||||
) {
|
||||
const trimmed = text.trim()
|
||||
// An image-only or context-only draft is still a message; only a fully
|
||||
// An attachment-only or context-only draft is still a message; only a fully
|
||||
// empty send is ignored (mirrors the idle empty-send guard).
|
||||
if (!trimmed && images.length === 0 && (context?.length ?? 0) === 0) {
|
||||
if (!trimmed && images.length === 0 && files.length === 0 && (context?.length ?? 0) === 0) {
|
||||
return
|
||||
}
|
||||
if (trimmed) {
|
||||
this.queuedMessage = this.queuedMessage ? `${this.queuedMessage}\n${trimmed}` : trimmed
|
||||
}
|
||||
if (images.length > 0) {
|
||||
const merged = [...this.queuedImages, ...images]
|
||||
if (merged.length > MAX_ATTACHED_IMAGES) {
|
||||
sendUserToast(`Only the first ${MAX_ATTACHED_IMAGES} images are kept.`, true)
|
||||
}
|
||||
this.queuedImages = merged.slice(0, MAX_ATTACHED_IMAGES)
|
||||
// The queue is a message draft like any other: attachments join under the
|
||||
// draft rules (fold, caps) — repeated submissions during one stream
|
||||
// aggregate into a single queued message.
|
||||
const droppedImages = images.length > 0 ? this.#queuedDraft.addImages(images) : 0
|
||||
if (droppedImages > 0) {
|
||||
sendUserToast(`Only the first ${MAX_ATTACHED_IMAGES} images are kept.`, true)
|
||||
}
|
||||
const droppedFiles = files.length > 0 ? this.#queuedDraft.addFiles(files).droppedAtCap : 0
|
||||
if (droppedFiles > 0) {
|
||||
sendUserToast(`Only the first ${MAX_ATTACHED_FILES} files are kept.`, true)
|
||||
}
|
||||
// Pin the context snapshot to the queued message. Several prompts can
|
||||
// queue during one stream and each pinned the selection at its press —
|
||||
@@ -1494,38 +1562,47 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether anything is waiting in the queue — an image-only or context-only
|
||||
* message has empty text. */
|
||||
/** Whether anything is waiting in the queue — an attachment-only or
|
||||
* context-only message has empty text. */
|
||||
#hasQueuedMessage(): boolean {
|
||||
return (
|
||||
this.queuedMessage !== '' ||
|
||||
this.queuedImages.length > 0 ||
|
||||
(this.queuedContext?.length ?? 0) > 0
|
||||
)
|
||||
return !this.#queuedDraft.isEmpty || (this.queuedContext?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
/** Detach the queue for sending. Text, images and context always leave together. */
|
||||
/** Detach the queue for sending. The draft lanes and context always leave together. */
|
||||
#takeQueue(): QueuedEntry {
|
||||
const taken = {
|
||||
text: this.queuedMessage,
|
||||
images: this.queuedImages,
|
||||
draft: this.#queuedDraft.take(),
|
||||
context: this.queuedContext
|
||||
}
|
||||
this.#clearQueue()
|
||||
this.queuedContext = undefined
|
||||
return taken
|
||||
}
|
||||
|
||||
#clearQueue() {
|
||||
this.queuedMessage = ''
|
||||
this.queuedImages = []
|
||||
this.#queuedDraft.clear()
|
||||
this.queuedContext = undefined
|
||||
}
|
||||
|
||||
/** Put a taken queue back after an auto-send bailed before becoming a turn. */
|
||||
/** Put a taken queue back after an auto-send bailed before becoming a turn.
|
||||
* Merged, not replaced: the user may have queued a follow-up while the
|
||||
* auto-send was in preflight, and clobbering it would silently lose it — the
|
||||
* taken entry's lanes land ahead of the follow-up's (they were written
|
||||
* first) and both entries' pinned contexts are unioned. */
|
||||
#restoreQueue(queued: QueuedEntry) {
|
||||
this.queuedMessage = queued.text
|
||||
this.queuedImages = queued.images
|
||||
this.queuedContext = queued.context
|
||||
this.#queuedDraft.prepend({
|
||||
text: queued.draft.text,
|
||||
images: queued.draft.images,
|
||||
files: queued.draft.files
|
||||
})
|
||||
if (queued.context?.length) {
|
||||
const merged = [...queued.context]
|
||||
for (const c of this.queuedContext ?? []) {
|
||||
if (!merged.some((m) => isSameContextElement(m, c))) {
|
||||
merged.push(c)
|
||||
}
|
||||
}
|
||||
this.queuedContext = merged
|
||||
}
|
||||
}
|
||||
|
||||
/** Put a draft's pinned DOM selector chips back as the live selection, so the
|
||||
@@ -1559,7 +1636,11 @@ export class AIChatManager {
|
||||
return
|
||||
}
|
||||
const queued = this.#takeQueue()
|
||||
const mergedIntoDraft = this.restoreToInput(queued.text, queued.images)
|
||||
const mergedIntoDraft = this.restoreToInput(
|
||||
queued.draft.text,
|
||||
queued.draft.images,
|
||||
queued.draft.files
|
||||
)
|
||||
// The queued draft pinned its own DOM context; restore it so sending from
|
||||
// the composer targets the element the draft was written for, not whatever
|
||||
// is selected now. If its text was prepended onto an existing draft, that
|
||||
@@ -1570,12 +1651,17 @@ export class AIChatManager {
|
||||
/** Put what the user typed back where they can see it: into the input
|
||||
* when it's mounted, otherwise back into the queue so it reappears with
|
||||
* the chat panel instead of being silently dropped. */
|
||||
private restoreToInput(text: string, images: AttachedImage[] = []): boolean {
|
||||
private restoreToInput(
|
||||
text: string,
|
||||
images: AttachedImage[] = [],
|
||||
files: AttachedTextFile[] = []
|
||||
): boolean {
|
||||
if (this.aiChatInput) {
|
||||
return this.aiChatInput.prependText(text, images) === true
|
||||
return this.aiChatInput.prependText(text, images, files) === true
|
||||
}
|
||||
this.queuedMessage = text
|
||||
this.queuedImages = images
|
||||
// Merge onto anything already queued (see #restoreQueue) — replacing would
|
||||
// silently drop a message queued while this one was in flight.
|
||||
this.#queuedDraft.prepend({ text, images, files })
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1967,19 +2053,130 @@ export class AIChatManager {
|
||||
// is false when a queued message is about to take over (a user cancel with
|
||||
// something queued) — then the rolled-back prompt is dropped rather than
|
||||
// shoved back into the input, so the handoff to the queued message is clean.
|
||||
private restoreUnsentTurn = (
|
||||
private restoreUnsentTurn = async (
|
||||
displayLenAfterUser: number,
|
||||
modelLenAfterUser: number,
|
||||
instructions: string,
|
||||
pastes: PasteAttachment[],
|
||||
restoreToInput: boolean = true,
|
||||
images: AttachedImage[] = []
|
||||
): boolean => {
|
||||
images: AttachedImage[] = [],
|
||||
files: AttachedTextFile[] = []
|
||||
): Promise<boolean> => {
|
||||
this.displayMessages = this.displayMessages.slice(0, displayLenAfterUser - 1)
|
||||
this.messages = this.messages.slice(0, modelLenAfterUser - 1)
|
||||
// The rolled-back turn's files must not stay registered: the message
|
||||
// referencing them is gone, so leaving them would keep stale content
|
||||
// readable by the tools on later turns.
|
||||
this.#syncMessageFiles()
|
||||
if (!restoreToInput) return false
|
||||
// An occupied composer declines the restore and keeps its own draft.
|
||||
return this.aiChatInput?.restoreInstructions(instructions, pastes, images) === true
|
||||
return this.aiChatInput?.restoreInstructions(instructions, pastes, images, files) === true
|
||||
}
|
||||
|
||||
// Bytes each live composer has staged toward its next send (committed
|
||||
// attachments + in-flight reads), keyed per composer instance. While a
|
||||
// message is being edited the bottom composer and the edit box are both
|
||||
// mounted; each must see the other's stage or two attaches could each spend
|
||||
// the full conversation budget and overflow the persisted transcript.
|
||||
#composerStaged = new SvelteMap<string, { editingIndex: number | null; bytes: number }>()
|
||||
|
||||
setComposerStaged(key: string, editingIndex: number | null, bytes: number) {
|
||||
this.#composerStaged.set(key, { editingIndex, bytes })
|
||||
}
|
||||
|
||||
clearComposerStaged(key: string) {
|
||||
this.#composerStaged.delete(key)
|
||||
}
|
||||
|
||||
/** Release the outgoing-files reservation identified by `key` (a per-send token).
|
||||
* Called once when sendRequest installs the bubble (the transcript then accounts
|
||||
* the files) and on every sendRequest path that exits before install — abandoning
|
||||
* the send leaves the files in the composer/queue, which reserves them, so a
|
||||
* stranded reservation would double-charge. Keyed per send so one send never
|
||||
* releases a reservation another owns. */
|
||||
#releaseOutgoingReservation(key: string | undefined) {
|
||||
if (key) this.clearComposerStaged(key)
|
||||
}
|
||||
|
||||
/** Attached-file bytes counted against MAX_CONVERSATION_FILE_BYTES by
|
||||
* everything except composer `selfKey` (whose stage replaces its own edited
|
||||
* message). A message ANOTHER composer is editing charges max(persisted,
|
||||
* editor stage): a cancelled edit returns the persisted attachments. */
|
||||
attachmentBytesExcluding(selfKey: string): number {
|
||||
const selfEditing = this.#composerStaged.get(selfKey)?.editingIndex ?? null
|
||||
const otherEdits = new Map<number, number>()
|
||||
for (const [k, v] of this.#composerStaged) {
|
||||
if (k !== selfKey && v.editingIndex !== null) otherEdits.set(v.editingIndex, v.bytes)
|
||||
}
|
||||
let total = 0
|
||||
for (const [i, m] of this.displayMessages.entries()) {
|
||||
if (i === selfEditing) continue
|
||||
let persisted = 0
|
||||
if ((m.role === 'user' || m.role === 'summary') && m.files) {
|
||||
for (const f of m.files) persisted += textByteLength(f.content)
|
||||
}
|
||||
const editorStage = otherEdits.get(i)
|
||||
total += editorStage !== undefined ? Math.max(persisted, editorStage) : persisted
|
||||
}
|
||||
for (const f of this.queuedFiles) total += textByteLength(f.content)
|
||||
// Composers not tied to an edited message stage genuinely new bytes;
|
||||
// editing composers were already accounted via the per-message max above.
|
||||
for (const [k, v] of this.#composerStaged) {
|
||||
if (k !== selfKey && v.editingIndex === null) total += v.bytes
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Reconcile the store's message-scoped file rows with what the transcript
|
||||
* references, joined on the stable file id. Runs on chat load/clear, after
|
||||
* rollbacks/truncations, and after compaction, so a chip the user can see is
|
||||
* always readable and a dropped message's file never lingers in the tool
|
||||
* surface. Also the single hydration point for transcripts persisted before
|
||||
* ids existed: the id is a deterministic content hash, so legacy rows gain
|
||||
* their permanent id here with no migration state. */
|
||||
#syncMessageFiles = (): void => {
|
||||
let hydrated = false
|
||||
const withIds = this.displayMessages.map((m) => {
|
||||
if ((m.role === 'user' || m.role === 'summary') && m.files?.some((f) => !f.id)) {
|
||||
hydrated = true
|
||||
return { ...m, files: withAttachedTextFileIds(m.files) }
|
||||
}
|
||||
return m
|
||||
})
|
||||
if (hydrated) this.displayMessages = withIds
|
||||
const wanted = new Map<string, AttachedTextFile & { id: string }>()
|
||||
for (const m of this.displayMessages) {
|
||||
// Summary messages carry the files of the turns they folded away.
|
||||
if ((m.role === 'user' || m.role === 'summary') && m.files) {
|
||||
for (const f of m.files) wanted.set(f.id!, f as AttachedTextFile & { id: string })
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.attachedFiles.syncMessageScoped([...wanted.values()])
|
||||
} catch (e) {
|
||||
console.error('Failed to sync message-attached files', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ids of message-scoped files whose only referencing user messages were
|
||||
* dropped from the API history by drop-oldest compaction (a negative `index`
|
||||
* marks a message whose API counterpart is gone). Their `## ATTACHED FILES`
|
||||
* reference no longer reaches the model — unlike summary compaction, which
|
||||
* carries the reference on the summary — so the roster must advertise them.
|
||||
* A file still referenced by a surviving message is not orphaned. */
|
||||
orphanedMessageFileIds(): Set<string> {
|
||||
const live = new Set<string>()
|
||||
const dropped = new Set<string>()
|
||||
for (const m of this.displayMessages) {
|
||||
if ((m.role === 'user' || m.role === 'summary') && m.files) {
|
||||
// A summary carries its files' reference in its own API message; that too
|
||||
// can be dropped by a later drop-oldest (negative index), orphaning them.
|
||||
const gone = m.index !== undefined && m.index < 0
|
||||
for (const f of m.files) (gone ? dropped : live).add(f.id ?? f.name)
|
||||
}
|
||||
}
|
||||
for (const n of live) dropped.delete(n)
|
||||
return dropped
|
||||
}
|
||||
|
||||
private notifyReasoningSummaryUnavailable = () => {
|
||||
@@ -2031,7 +2228,11 @@ export class AIChatManager {
|
||||
// Inject the attached-files roster at request time (re-read each iteration)
|
||||
// so it always reflects the live file list without reactive bookkeeping.
|
||||
if (self.mode === AIMode.GLOBAL && self.attachedFiles.count > 0) {
|
||||
return appendAttachedFilesRoster(base, self.attachedFiles)
|
||||
return appendAttachedFilesRoster(
|
||||
base,
|
||||
self.attachedFiles,
|
||||
self.orphanedMessageFileIds()
|
||||
)
|
||||
}
|
||||
return base
|
||||
},
|
||||
@@ -2220,6 +2421,7 @@ export class AIChatManager {
|
||||
instructions?: string
|
||||
pastes?: PasteAttachment[]
|
||||
images?: AttachedImage[]
|
||||
files?: AttachedTextFile[]
|
||||
mode?: AIMode
|
||||
lang?: ScriptLang | 'bunnative'
|
||||
isPreprocessor?: boolean
|
||||
@@ -2237,6 +2439,10 @@ export class AIChatManager {
|
||||
* it, so the composer restore must not also fire (the draft would exist
|
||||
* twice — queue chip and composer). */
|
||||
queued?: boolean
|
||||
/** Per-resend reservation token (see restartGeneration): the bytes staged
|
||||
* under it are released once this send installs its bubble or exits before
|
||||
* install. Absent on normal sends, so they never touch a resend's reservation. */
|
||||
resendReservationKey?: string
|
||||
} = {}
|
||||
) => {
|
||||
// Returns whether the input was consumed: true when it was sent as a chat
|
||||
@@ -2244,8 +2450,17 @@ export class AIChatManager {
|
||||
// without being acted on (mode hidden, empty non-GLOBAL draft, beforeSend
|
||||
// failed). The queue flush restores the queued message only on false, so a
|
||||
// consumed command isn't re-queued and re-fired into the next conversation.
|
||||
//
|
||||
// Reservation token for this send's outgoing files. A resend arrives with one
|
||||
// already set by restartGeneration (it must reserve earlier, before its own
|
||||
// pre-send slice); a normal/queued send mints one below, just before the
|
||||
// attachment-upkeep awaits open a gap. Released once the bubble installs or the
|
||||
// send exits before install. Kept in a mutable local so every exit path
|
||||
// releases the right key.
|
||||
let reservationKey = options.resendReservationKey
|
||||
const requestedMode = options.mode ?? this.mode
|
||||
if (!isAIModeVisible(requestedMode)) {
|
||||
this.#releaseOutgoingReservation(reservationKey)
|
||||
return false
|
||||
}
|
||||
this.changeMode(requestedMode, undefined, {
|
||||
@@ -2260,16 +2475,22 @@ export class AIChatManager {
|
||||
}
|
||||
// A text-free GLOBAL draft is a real turn — rendered as its context chips
|
||||
// (no bubble), with the empty-message marker substituted further down —
|
||||
// but only when it carries something for the model: images or selected
|
||||
// context elements. A bare accidental Enter is dropped in every mode (in
|
||||
// editor copilots it would burn a turn for nothing). Gate on requestedMode,
|
||||
// not this.mode: changeMode can decline a switch (e.g. SCRIPT with no
|
||||
// model), and a declined non-GLOBAL request must not slip through as a
|
||||
// GLOBAL empty turn. Image-bearing non-GLOBAL drafts still pass through
|
||||
// to the switch-back refusal below so attachments aren't silently lost.
|
||||
if (!this.instructions.trim() && (options.images?.length ?? 0) === 0) {
|
||||
// but only when it carries something for the model: images, files, or
|
||||
// selected context elements. A bare accidental Enter is dropped in every
|
||||
// mode (in editor copilots it would burn a turn for nothing). Gate on
|
||||
// requestedMode, not this.mode: changeMode can decline a switch (e.g.
|
||||
// SCRIPT with no model), and a declined non-GLOBAL request must not slip
|
||||
// through as a GLOBAL empty turn. Attachment-bearing non-GLOBAL drafts
|
||||
// still pass through to the switch-back refusal below so attachments
|
||||
// aren't silently lost.
|
||||
if (
|
||||
!this.instructions.trim() &&
|
||||
(options.images?.length ?? 0) === 0 &&
|
||||
(options.files?.length ?? 0) === 0
|
||||
) {
|
||||
const contextEls = options.contextOverride ?? this.contextManager?.getSelectedContext() ?? []
|
||||
if (requestedMode !== AIMode.GLOBAL || contextEls.length === 0) {
|
||||
this.#releaseOutgoingReservation(reservationKey)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2281,6 +2502,11 @@ export class AIChatManager {
|
||||
// conversation.
|
||||
if (this.isSessionChat && this.mode === AIMode.GLOBAL) {
|
||||
const trimmed = this.instructions.trim()
|
||||
// A local command consumes the send without installing a bubble; an edit
|
||||
// resolved to `/clear` or `/compact` must not strand its resend reservation.
|
||||
if (COMPACT_COMMAND_RE.test(trimmed) || CLEAR_COMMAND_RE.test(trimmed)) {
|
||||
this.#releaseOutgoingReservation(reservationKey)
|
||||
}
|
||||
// `/compact`: summarize the conversation locally to free up context.
|
||||
if (COMPACT_COMMAND_RE.test(trimmed)) {
|
||||
this.instructions = ''
|
||||
@@ -2294,6 +2520,19 @@ export class AIChatManager {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Reserve the outgoing files' bytes now, before the upkeep awaits below: the
|
||||
// composer (or queue) already cleared them, so without this reservation they
|
||||
// are unaccounted during the gap and a fresh drop could spend the same
|
||||
// headroom, overflowing the cap once this bubble lands. A resend already holds
|
||||
// its own reservation (reused via reservationKey), so only mint for others.
|
||||
if (!reservationKey && (options.files?.length ?? 0) > 0) {
|
||||
reservationKey = `send:${createLongHash()}`
|
||||
this.setComposerStaged(
|
||||
reservationKey,
|
||||
null,
|
||||
options.files!.reduce((sum, f) => sum + textByteLength(f.content), 0)
|
||||
)
|
||||
}
|
||||
// Re-grant any locked File System Access handles within this send gesture, so the
|
||||
// file tools can read the live files. requestPermission() needs a user gesture, and
|
||||
// this runs before the first await/network call while the Send click is still active.
|
||||
@@ -2313,17 +2552,31 @@ export class AIChatManager {
|
||||
// Context elements and the snapshot are attached after beforeSend (see below).
|
||||
const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user')
|
||||
const pastes = options.pastes ?? []
|
||||
// Images ride only on GLOBAL turns, but the composer stays mounted across
|
||||
// a mode switch, so chips attached in GLOBAL can arrive with a send in any
|
||||
// mode. Refuse and restore rather than silently dropping attachments the
|
||||
// user can see. This sits past the awaits above on purpose: the composer
|
||||
// clears itself synchronously right after calling sendRequest, so an
|
||||
// earlier restore would be wiped. Queued drafts are the caller's to
|
||||
// restore (it re-queues on false).
|
||||
if ((options.images?.length ?? 0) > 0 && this.mode !== AIMode.GLOBAL) {
|
||||
sendUserToast('Switch back to the chat mode to send images. Your message was kept.', true)
|
||||
// Attachments (images, text files) ride only on GLOBAL turns, but the
|
||||
// composer stays mounted across a mode switch, so chips attached in GLOBAL
|
||||
// can arrive with a send in any mode. Refuse and restore rather than
|
||||
// silently dropping attachments the user can see. This sits past the
|
||||
// awaits above on purpose: the composer clears itself synchronously right
|
||||
// after calling sendRequest, so an earlier restore would be wiped. Queued
|
||||
// drafts are the caller's to restore (it re-queues on false).
|
||||
if (
|
||||
((options.images?.length ?? 0) > 0 || (options.files?.length ?? 0) > 0) &&
|
||||
this.mode !== AIMode.GLOBAL
|
||||
) {
|
||||
sendUserToast(
|
||||
'Switch back to the chat mode to send attachments. Your message was kept.',
|
||||
true
|
||||
)
|
||||
// Abandoned before install; the files go back to the composer, which
|
||||
// re-reserves them, so release this send's outgoing-files reservation.
|
||||
this.#releaseOutgoingReservation(reservationKey)
|
||||
if (!options.queued) {
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, options.images ?? [])
|
||||
this.aiChatInput?.restoreInstructions(
|
||||
this.instructions,
|
||||
pastes,
|
||||
options.images ?? [],
|
||||
options.files ?? []
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -2331,6 +2584,12 @@ export class AIChatManager {
|
||||
// repeated here, not just at attach time: the model can be switched to a
|
||||
// text-only one after attaching, and sending the image then fails the turn.
|
||||
const requestedImages = options.images ?? []
|
||||
// Text files pass regardless of vision support — the prompt carries only
|
||||
// references; content is read via the file tools. Hydrated so every copy of
|
||||
// this turn (bubble, prompt, registration, restore) carries the stable id —
|
||||
// only edit/retry of a pre-id transcript can arrive without one, and the
|
||||
// hash is deterministic, so hydration reproduces the original id.
|
||||
const files = withAttachedTextFileIds(options.files ?? [])
|
||||
const sendModel = tryGetCurrentModel()
|
||||
const modelIsBlind = !!sendModel && !modelSupportsVision(sendModel.provider, sendModel.model)
|
||||
if (requestedImages.length > 0 && modelIsBlind) {
|
||||
@@ -2338,7 +2597,7 @@ export class AIChatManager {
|
||||
// put them back in the composer instead of silently discarding them
|
||||
// (the input already cleared itself optimistically on send). Queued
|
||||
// drafts are the caller's to restore (it re-queues on false).
|
||||
if (!this.instructions.trim()) {
|
||||
if (!this.instructions.trim() && files.length === 0) {
|
||||
sendUserToast(`${sendModel.model} can't read images. Switch to a vision model first.`, true)
|
||||
if (!options.queued) this.restoreToInput('', requestedImages)
|
||||
return false
|
||||
@@ -2366,9 +2625,14 @@ export class AIChatManager {
|
||||
// Same objects as the API message's parts: sharing the exact data URL
|
||||
// lets the history's blob store persist one copy for both.
|
||||
images: images.length > 0 ? images : undefined,
|
||||
files: files.length > 0 ? files : undefined,
|
||||
index: this.messages.length // matching with actual messages index. not -1 because it's not yet added to the messages array
|
||||
}
|
||||
]
|
||||
// The bubble now carries the outgoing files, so the transcript accounts them;
|
||||
// release the reservation that bridged the preflight gap. A beforeSend
|
||||
// rollback below restores them to the composer, which re-reserves.
|
||||
this.#releaseOutgoingReservation(reservationKey)
|
||||
// Undo the optimistic bubble + loading/label. Shared by the beforeSend-failure and
|
||||
// pre-flight-cancel paths below; callers put the message back in the composer.
|
||||
const rollbackOptimisticSend = () => {
|
||||
@@ -2388,7 +2652,7 @@ export class AIChatManager {
|
||||
console.error('AIChatManager beforeSend hook failed', e)
|
||||
rollbackOptimisticSend()
|
||||
if (!options.queued) {
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images)
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files)
|
||||
}
|
||||
sendUserToast(
|
||||
`Could not prepare the session before sending: ${
|
||||
@@ -2414,14 +2678,15 @@ export class AIChatManager {
|
||||
if (this.wasCancelledByUser() && this.#hasQueuedMessage()) {
|
||||
const next = this.#takeQueue()
|
||||
const accepted = await this.sendRequest({
|
||||
instructions: next.text,
|
||||
images: next.images,
|
||||
instructions: next.draft.text,
|
||||
images: next.draft.images,
|
||||
files: next.draft.files,
|
||||
contextOverride: next.context,
|
||||
queued: true
|
||||
})
|
||||
if (accepted === false) this.#restoreQueue(next)
|
||||
} else {
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images)
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -2564,6 +2829,19 @@ export class AIChatManager {
|
||||
throw new Error('No script options passed')
|
||||
}
|
||||
|
||||
// Message-attached files travel as references: the prompt lists them by id
|
||||
// and the model reads their content via the file tools. Register them in
|
||||
// the store before the request goes out so this turn's reads can already
|
||||
// see them. Registration failure must not block the send — the reference
|
||||
// just reads as missing and the model reports it.
|
||||
if (this.mode === AIMode.GLOBAL && files.length > 0) {
|
||||
try {
|
||||
this.attachedFiles.registerMessageFiles(files as (AttachedTextFile & { id: string })[])
|
||||
} catch (e) {
|
||||
console.error('Failed to register message-attached files', e)
|
||||
}
|
||||
}
|
||||
|
||||
let userMessage: ChatCompletionMessageParam = {
|
||||
role: 'user',
|
||||
content: ''
|
||||
@@ -2592,7 +2870,8 @@ export class AIChatManager {
|
||||
case AIMode.GLOBAL:
|
||||
userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, {
|
||||
workspace: this.operatingWorkspace,
|
||||
images: sentImages
|
||||
images: sentImages,
|
||||
files: files
|
||||
})
|
||||
break
|
||||
case AIMode.APP:
|
||||
@@ -2655,6 +2934,9 @@ export class AIChatManager {
|
||||
this.contextUsage = Math.max(0, this.contextUsage - freed)
|
||||
}
|
||||
}
|
||||
// Reconcile file registrations with the compacted transcript — the
|
||||
// summary message carries the folded-away turns' files forward.
|
||||
this.#syncMessageFiles()
|
||||
await this.historyManager.saveChat(
|
||||
this.displayMessages,
|
||||
this.messages,
|
||||
@@ -2813,13 +3095,14 @@ export class AIChatManager {
|
||||
// about to auto-send (see the flush below) — drop the rolled-back
|
||||
// prompt instead of restoring it to the input so the handoff is clean.
|
||||
const willAutoSendQueued = this.wasCancelledByUser() && this.#hasQueuedMessage()
|
||||
const textRestored = this.restoreUnsentTurn(
|
||||
const textRestored = await this.restoreUnsentTurn(
|
||||
displayLenAfterUser,
|
||||
modelLenAfterUser,
|
||||
sentInstructions,
|
||||
sentPastes,
|
||||
!willAutoSendQueued,
|
||||
sentImages
|
||||
sentImages,
|
||||
files
|
||||
)
|
||||
// restoreUnsentTurn hands the text/pastes/images back for a resend, but
|
||||
// the DOM selector chips were already consumed from the live selection
|
||||
@@ -2953,8 +3236,9 @@ export class AIChatManager {
|
||||
if ((turnCommittedCleanly || this.wasCancelledByUser()) && this.#hasQueuedMessage()) {
|
||||
const next = this.#takeQueue()
|
||||
const accepted = await this.sendRequest({
|
||||
instructions: next.text,
|
||||
images: next.images,
|
||||
instructions: next.draft.text,
|
||||
images: next.draft.images,
|
||||
files: next.draft.files,
|
||||
contextOverride: next.context,
|
||||
queued: true
|
||||
})
|
||||
@@ -3025,12 +3309,13 @@ export class AIChatManager {
|
||||
)
|
||||
}
|
||||
|
||||
restartGeneration = (
|
||||
restartGeneration = async (
|
||||
displayMessageIndex: number,
|
||||
newContent?: string,
|
||||
pastes?: PasteAttachment[],
|
||||
images?: AttachedImage[],
|
||||
editedContext?: ContextElement[]
|
||||
editedContext?: ContextElement[],
|
||||
files?: AttachedTextFile[]
|
||||
) => {
|
||||
const userMessage = this.displayMessages[displayMessageIndex]
|
||||
|
||||
@@ -3038,24 +3323,37 @@ export class AIChatManager {
|
||||
throw new Error('No user message found at the specified index')
|
||||
}
|
||||
|
||||
// Read while both arrays are intact: storedImages pairs the API message with
|
||||
// its transcript entry, and the truncations below drop them.
|
||||
const sentImages = this.storedImages(displayMessageIndex)
|
||||
|
||||
// Remove all messages including and after the specified user message
|
||||
this.displayMessages = this.displayMessages.slice(0, displayMessageIndex)
|
||||
|
||||
// Find corresponding message in actual messages and remove it and everything
|
||||
// after it. A negative index marks a message whose API counterpart was
|
||||
// removed by drop-oldest compaction — everything before it went too, so
|
||||
// restarting from it restarts from an empty history.
|
||||
let actualMessageIndex =
|
||||
userMessage.index < 0 ? 0 : this.messages.findIndex((_, i) => i === userMessage.index)
|
||||
|
||||
// Resolve the API restart point BEFORE reserving bytes or truncating: a
|
||||
// stale index must fail while nothing has been mutated, or the transcript
|
||||
// would be left truncated with the reservation leaked. A negative index
|
||||
// marks a message whose API counterpart was removed by drop-oldest
|
||||
// compaction — everything before it went too, so restarting from it
|
||||
// restarts from an empty history.
|
||||
const actualMessageIndex =
|
||||
userMessage.index < 0 ? 0 : userMessage.index < this.messages.length ? userMessage.index : -1
|
||||
if (actualMessageIndex === -1) {
|
||||
throw new Error('No actual user message found to restart from')
|
||||
}
|
||||
|
||||
// Read while both arrays are intact: storedImages pairs the API message with
|
||||
// its transcript entry, and the truncations below drop them.
|
||||
const sentImages = this.storedImages(displayMessageIndex)
|
||||
|
||||
// Reserve the resent files' bytes across the gap between the edit box
|
||||
// unmounting and the optimistic message landing. A per-resend token owns the
|
||||
// reservation (sendRequest releases only this key) so an unrelated or
|
||||
// concurrent send never clears it. Set before the slice below removes the
|
||||
// message from the transcript, so those bytes are always accounted.
|
||||
const resendReservationKey = `resend:${createLongHash()}`
|
||||
const resentFiles = files ?? userMessage.files ?? []
|
||||
this.setComposerStaged(
|
||||
resendReservationKey,
|
||||
null,
|
||||
resentFiles.reduce((sum, f) => sum + textByteLength(f.content), 0)
|
||||
)
|
||||
|
||||
// Remove all messages including and after the specified user message
|
||||
this.displayMessages = this.displayMessages.slice(0, displayMessageIndex)
|
||||
this.messages = this.messages.slice(0, actualMessageIndex)
|
||||
|
||||
// The last report described the pre-rewind history; clear it. Readers
|
||||
@@ -3073,11 +3371,19 @@ export class AIChatManager {
|
||||
// contextElements. `undefined` for modes that don't attach context leaves the
|
||||
// live-selection behavior. An empty array is a deliberate "no context".
|
||||
this.instructions = newContent ?? userMessage.content
|
||||
// Prune the truncated messages' file registrations BEFORE the resend
|
||||
// re-registers its own — the other way around would delete the fresh rows.
|
||||
this.#syncMessageFiles()
|
||||
this.sendRequest({
|
||||
pastes: pastes ?? userMessage.pastes,
|
||||
contextOverride: editedContext ?? userMessage.contextElements,
|
||||
contextOverrideOrigin: 'replay',
|
||||
images: images ?? sentImages
|
||||
images: images ?? sentImages,
|
||||
// The bubble copy is authoritative for files: the API message carries
|
||||
// only a reference (content lives in the store), so nothing ever strips
|
||||
// it the way providers strip image parts from history.
|
||||
files: files ?? userMessage.files,
|
||||
resendReservationKey
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3133,6 +3439,8 @@ export class AIChatManager {
|
||||
// session, so "New chat" must clear them — otherwise the next, unrelated conversation
|
||||
// would still get the previous file roster and could read/search it.
|
||||
if (!this.isSessionChat) this.attachedFiles.clear()
|
||||
// Message-attached rows belong to the conversation just left in every case.
|
||||
this.#syncMessageFiles()
|
||||
this.syncArtifactsSession()
|
||||
this.onChatRotated?.(this.historyManager.getCurrentChatId())
|
||||
}
|
||||
@@ -3170,6 +3478,10 @@ export class AIChatManager {
|
||||
}
|
||||
if (this.backgroundJobs.length > 0) this.backgroundJobs = [...this.backgroundJobs]
|
||||
this.#ensureJobPoller()
|
||||
// Message-attached files live in the transcript, not in the store's
|
||||
// persistence — rebuild their rows so the loaded chat's references are
|
||||
// readable (and the previous chat's are pruned).
|
||||
this.#syncMessageFiles()
|
||||
this.#automaticScroll = true
|
||||
this.syncArtifactsSession()
|
||||
this.onChatRotated?.(id)
|
||||
|
||||
@@ -495,7 +495,7 @@ describe('AIChatManager queued messages', () => {
|
||||
manager.dequeueMessage()
|
||||
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
expect(input.prependText).toHaveBeenCalledWith('line one\nline two', [])
|
||||
expect(input.prependText).toHaveBeenCalledWith('line one\nline two', [], [])
|
||||
})
|
||||
|
||||
const img = (n: string): AttachedImage => ({
|
||||
@@ -994,7 +994,7 @@ describe('AIChatManager queued messages', () => {
|
||||
await manager.sendRequest({ instructions: '', images: [img('a')] })
|
||||
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(input.prependText).toHaveBeenCalledWith('', [img('a')])
|
||||
expect(input.prependText).toHaveBeenCalledWith('', [img('a')], [])
|
||||
mocks.tryGetCurrentModel.mockReturnValue(model)
|
||||
})
|
||||
|
||||
@@ -1086,7 +1086,7 @@ describe('AIChatManager queued messages', () => {
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(input.restoreInstructions).toHaveBeenCalledWith('find it', [], [img('a')])
|
||||
expect(input.restoreInstructions).toHaveBeenCalledWith('find it', [], [img('a')], [])
|
||||
})
|
||||
|
||||
// A refused queued draft is the caller's to restore (it re-queues on false) —
|
||||
@@ -1194,7 +1194,235 @@ describe('AIChatManager queued messages', () => {
|
||||
manager.dequeueMessage()
|
||||
|
||||
expect(manager.queuedImages).toEqual([])
|
||||
expect(input.prependText).toHaveBeenCalledWith('', [img('a')])
|
||||
expect(input.prependText).toHaveBeenCalledWith('', [img('a')], [])
|
||||
})
|
||||
|
||||
it('queues a file-only message and restores it on dequeue', () => {
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
const file = { name: 'notes.md', content: 'hello' }
|
||||
manager.queueMessage('', [], undefined, [file])
|
||||
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
expect(manager.queuedFiles).toMatchObject([file])
|
||||
const queued = manager.queuedFiles
|
||||
|
||||
manager.dequeueMessage()
|
||||
|
||||
expect(manager.queuedFiles).toEqual([])
|
||||
expect(input.prependText).toHaveBeenCalledWith('', [], queued)
|
||||
})
|
||||
|
||||
it('normalizes files aggregated into one queued message', () => {
|
||||
// Repeated submissions during a stream fold into one queued message, so the
|
||||
// queue applies the same commit normalization as the composer: identical
|
||||
// re-attaches dedupe (no wasted slot), same-name clashes get the courtesy
|
||||
// rename, distinct files survive.
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
manager.queueMessage('', [], undefined, [{ name: 'notes.md', content: 'alpha' }])
|
||||
manager.queueMessage('', [], undefined, [
|
||||
{ name: 'notes.md', content: 'alpha' },
|
||||
{ name: 'notes.md', content: 'bravo' }
|
||||
])
|
||||
|
||||
expect(manager.queuedFiles.map((f) => f.name)).toEqual(['notes.md', 'notes (2).md'])
|
||||
expect(manager.queuedFiles.map((f) => f.content)).toEqual(['alpha', 'bravo'])
|
||||
})
|
||||
|
||||
// While editing an earlier message the bottom composer and the edit box are
|
||||
// both mounted. Each enforces MAX_CONVERSATION_FILE_BYTES at attach time, so
|
||||
// each must see the other's stage or two attaches could each spend the full
|
||||
// budget and overflow the persisted transcript.
|
||||
it('counts every other live composer stage in the attachment budget', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.displayMessages = [
|
||||
{ role: 'user', content: 'edited', files: [{ name: 'a.md', content: 'X'.repeat(300) }] },
|
||||
{ role: 'user', content: 'kept', files: [{ name: 'b.md', content: 'Y'.repeat(500) }] }
|
||||
] as any
|
||||
|
||||
// Bottom composer staged 4MB; edit box (editing message 0) staged 900KB.
|
||||
manager.setComposerStaged('main', null, 4_000_000)
|
||||
manager.setComposerStaged('edit', 0, 900_000)
|
||||
|
||||
// From the bottom composer: message 0 is skipped (its editor's stage stands
|
||||
// in for it), message 1 counts, and the edit box's 900KB is visible.
|
||||
expect(manager.attachmentBytesExcluding('main')).toBe(500 + 900_000)
|
||||
// From the edit box: message 0 skipped, message 1 counts, bottom's 4MB visible.
|
||||
expect(manager.attachmentBytesExcluding('edit')).toBe(500 + 4_000_000)
|
||||
|
||||
manager.clearComposerStaged('edit')
|
||||
expect(manager.attachmentBytesExcluding('main')).toBe(300 + 500)
|
||||
})
|
||||
|
||||
// The edit box unmounts (dropping its stage) the instant the user submits, but
|
||||
// restartGeneration then awaits registry sync + upkeep before the resent bubble
|
||||
// lands in the transcript. During that gap the resent files must stay reserved,
|
||||
// or the bottom composer could attach into the temporary headroom and overflow.
|
||||
it('reserves resent files across the restartGeneration gap', async () => {
|
||||
replyWith('done')
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
const fileRow = { name: 'a.md', content: 'X'.repeat(3000) }
|
||||
manager.displayMessages = [{ role: 'user', content: 'orig', files: [fileRow], index: 0 }] as any
|
||||
manager.messages = [{ role: 'user', content: 'orig' }] as any
|
||||
|
||||
// refreshFolders runs inside sendRequest AFTER the edited message was sliced
|
||||
// out but BEFORE the resent bubble is installed — the one moment the gap is
|
||||
// open. The reservation must cover the resent bytes there.
|
||||
let observed: number | undefined
|
||||
vi.spyOn(manager.attachedFiles, 'refreshFolders').mockImplementation(async () => {
|
||||
observed = manager.attachmentBytesExcluding('probe')
|
||||
})
|
||||
|
||||
await manager.restartGeneration(0)
|
||||
// Drain the resend turn fully (its runChatLoop resolves immediately) so no
|
||||
// async work bleeds into a later test's shared-mock call counts.
|
||||
for (let i = 0; i < 50; i++) await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(observed).toBe(3000)
|
||||
// Once the turn installs the bubble, the reservation is released — the
|
||||
// transcript now accounts those bytes on its own.
|
||||
expect(manager.attachmentBytesExcluding('probe')).toBe(3000)
|
||||
})
|
||||
|
||||
// A normal send clears the composer's files immediately, but sendRequest awaits
|
||||
// attachment upkeep (regrant/refresh) before installing the bubble. The outgoing
|
||||
// bytes must stay reserved across that gap or a fresh drop could overflow the cap.
|
||||
it('reserves a normal send outgoing files across the preflight gap', async () => {
|
||||
replyWith('done')
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
|
||||
let observed: number | undefined
|
||||
vi.spyOn(manager.attachedFiles, 'refreshFolders').mockImplementation(async () => {
|
||||
observed = manager.attachmentBytesExcluding('probe')
|
||||
})
|
||||
|
||||
await manager.sendRequest({
|
||||
instructions: 'hi',
|
||||
files: [{ name: 'a.md', content: 'X'.repeat(2500) }]
|
||||
})
|
||||
for (let i = 0; i < 50; i++) await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// Reserved during upkeep (before the bubble lands), then accounted by the
|
||||
// installed transcript once the reservation is released.
|
||||
expect(observed).toBe(2500)
|
||||
expect(manager.attachmentBytesExcluding('probe')).toBe(2500)
|
||||
})
|
||||
|
||||
// A local command (/clear, /compact) consumes the send and returns before a
|
||||
// bubble installs, so an edit resolved to one must not strand its reservation.
|
||||
it('releases the resend reservation when an edit resolves to a local command', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
manager.isSessionChat = true
|
||||
vi.spyOn(manager, 'compactManually').mockResolvedValue()
|
||||
const fileRow = { name: 'a.md', content: 'X'.repeat(2000) }
|
||||
manager.displayMessages = [{ role: 'user', content: 'orig', files: [fileRow], index: 0 }] as any
|
||||
manager.messages = [{ role: 'user', content: 'orig' }] as any
|
||||
|
||||
await manager.restartGeneration(0, '/compact')
|
||||
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// No stranded reservation: the abandoned resend charges nothing.
|
||||
expect(manager.attachmentBytesExcluding('probe')).toBe(0)
|
||||
})
|
||||
|
||||
// The reservation is keyed per resend, so a normal (or concurrent) send that
|
||||
// carries no token must never release a resend reservation it doesn't own.
|
||||
it('a normal send does not release another send resend reservation', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
// An in-flight resend owns this reservation.
|
||||
manager.setComposerStaged('resend:other', null, 4000)
|
||||
|
||||
// A normal send that bails early (empty draft) carries no reservation key.
|
||||
await manager.sendRequest({ instructions: ' ' })
|
||||
|
||||
expect(manager.attachmentBytesExcluding('probe')).toBe(4000)
|
||||
})
|
||||
|
||||
// Drop-oldest compaction (summary fallback) removes API messages without a
|
||||
// summary, so a folded message's `## ATTACHED FILES` reference no longer reaches
|
||||
// the model. Its file (index < 0) must be advertised through the roster instead.
|
||||
it('flags message files whose referencing message was dropped by compaction', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.displayMessages = [
|
||||
{ role: 'user', content: 'a', index: -1, files: [{ name: 'dropped.md', content: 'x' }] },
|
||||
{ role: 'user', content: 'b', index: 0, files: [{ name: 'live.md', content: 'y' }] },
|
||||
// Referenced by BOTH a dropped and a surviving message → still visible, not orphaned.
|
||||
{ role: 'user', content: 'c', index: -1, files: [{ name: 'shared.md', content: 'z' }] },
|
||||
{ role: 'user', content: 'd', index: 1, files: [{ name: 'shared.md', content: 'z' }] }
|
||||
] as any
|
||||
|
||||
expect([...manager.orphanedMessageFileIds()]).toEqual(['dropped.md'])
|
||||
})
|
||||
|
||||
// A summary carries its folded files' reference on its own API message; if a
|
||||
// later drop-oldest (summary fallback) removes that message, the reference is
|
||||
// gone and the files must move to the roster like any other orphan.
|
||||
it('orphans summary-carried files when drop-oldest removes the summary', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.messages = [
|
||||
{ role: 'user', content: 'summary api message' },
|
||||
{ role: 'user', content: 'tail' }
|
||||
] as any
|
||||
manager.displayMessages = [
|
||||
{ role: 'summary', content: 's', index: 0, files: [{ name: 'folded.md', content: 'x' }] },
|
||||
{ role: 'user', content: 'tail', index: 1 }
|
||||
] as any
|
||||
|
||||
// Summary API message present → its files are still referenced.
|
||||
expect([...manager.orphanedMessageFileIds()]).toEqual([])
|
||||
|
||||
// Drop-oldest removes the summary's API message and re-bases indices.
|
||||
manager.compactOldestMessages(1)
|
||||
|
||||
expect([...manager.orphanedMessageFileIds()]).toEqual(['folded.md'])
|
||||
})
|
||||
|
||||
it('a stale restart index fails before touching the transcript or the budget', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.displayMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'old',
|
||||
index: 5,
|
||||
files: [{ name: 'a.md', content: 'X'.repeat(100) }]
|
||||
},
|
||||
{ role: 'assistant', content: 'reply' }
|
||||
] as any
|
||||
manager.messages = [{ role: 'user', content: 'old' }] as any // index 5 is stale
|
||||
|
||||
await expect(manager.restartGeneration(0)).rejects.toThrow(
|
||||
'No actual user message found to restart from'
|
||||
)
|
||||
// Nothing was mutated and no resend reservation lingers: the budget still
|
||||
// counts only the transcript's 100 bytes.
|
||||
expect(manager.displayMessages).toHaveLength(2)
|
||||
expect(manager.messages).toHaveLength(1)
|
||||
expect(manager.attachmentBytesExcluding('probe')).toBe(100)
|
||||
})
|
||||
|
||||
// An edit is not committed until send, so cancelling it returns the message's
|
||||
// persisted attachments. Charging only the (possibly emptied) edit stage would
|
||||
// hand the bottom composer headroom that vanishes on cancel — remove the files
|
||||
// in the editor, fill the bottom draft, cancel, and the transcript overflows.
|
||||
it('charges an edited message at its persisted size until the edit commits', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.displayMessages = [
|
||||
{ role: 'user', content: 'big', files: [{ name: 'a.md', content: 'X'.repeat(4000) }] }
|
||||
] as any
|
||||
|
||||
// Edit box mounted on message 0 with its attachment removed (stage 0):
|
||||
// the bottom composer must still see the 4000 persisted bytes.
|
||||
manager.setComposerStaged('edit', 0, 0)
|
||||
expect(manager.attachmentBytesExcluding('main')).toBe(4000)
|
||||
|
||||
// Once the editor stages more than the original, the larger figure wins.
|
||||
manager.setComposerStaged('edit', 0, 9000)
|
||||
expect(manager.attachmentBytesExcluding('main')).toBe(9000)
|
||||
})
|
||||
|
||||
it('drops queued images when the conversation is switched away', async () => {
|
||||
@@ -1391,6 +1619,29 @@ describe('AIChatManager queued messages', () => {
|
||||
expect(chips.map((c) => c.selector).sort()).toEqual(['div.a', 'div.b'])
|
||||
})
|
||||
|
||||
it('merges a follow-up queued during a failed auto-send instead of clobbering it', async () => {
|
||||
replyWith('done')
|
||||
const manager = createManager(createInputMock())
|
||||
const chipA = { type: 'app_dom_selector', selector: '#a', appPath: 'p' } as any
|
||||
const chipB = { type: 'app_dom_selector', selector: '#b', appPath: 'p' } as any
|
||||
manager.beforeSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockImplementationOnce(async () => {
|
||||
// A follow-up arrives while the queued auto-send is in preflight; the
|
||||
// failed send's restore must merge on top of it, not replace it.
|
||||
manager.queueMessage('typed during preflight', [], [chipB])
|
||||
throw new Error('workspace commit failed')
|
||||
})
|
||||
|
||||
manager.queueMessage('first queued', [], [chipA])
|
||||
await manager.sendRequest({ instructions: 'first' })
|
||||
|
||||
expect(manager.queuedMessage).toBe('first queued\n\ntyped during preflight')
|
||||
// Both entries' pinned contexts survive the restore, older first.
|
||||
expect(manager.queuedContext?.map((c: any) => c.selector)).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('re-queues the message when its auto-send is rejected by beforeSend', async () => {
|
||||
replyWith('done')
|
||||
const input = createInputMock()
|
||||
@@ -1422,7 +1673,7 @@ describe('AIChatManager queued messages', () => {
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(input.restoreInstructions).toHaveBeenCalledWith('look', [], [img('a')])
|
||||
expect(input.restoreInstructions).toHaveBeenCalledWith('look', [], [img('a')], [])
|
||||
// the optimistic bubble is rolled back
|
||||
expect(manager.displayMessages).toHaveLength(0)
|
||||
})
|
||||
@@ -1851,6 +2102,32 @@ describe('AIChatManager context compaction', () => {
|
||||
expect(manager.contextUsage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries folded-away message files on the summary', async () => {
|
||||
mocks.getCurrentModel.mockReturnValue(gpt4oModel)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel)
|
||||
mocks.getNonStreamingCompletion.mockResolvedValue(
|
||||
'<analysis>s</analysis><summary>SUM</summary>'
|
||||
)
|
||||
const manager = new AIChatManager()
|
||||
seedForSummary(manager)
|
||||
const file = { name: 'notes.md', content: 'hello' }
|
||||
// The identical file on TWO folded turns (identical content registers under
|
||||
// one name) must carry as ONE summary entry.
|
||||
manager.displayMessages = manager.displayMessages.map((m, i) =>
|
||||
(i === 0 || i === 2) && m.role === 'user' ? { ...m, files: [file] } : m
|
||||
)
|
||||
|
||||
await manager.sendRequest()
|
||||
|
||||
// The summary display message carries the folded-away file once, the API
|
||||
// summary references it as still-readable, and the registry keeps its row.
|
||||
expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', files: [file] })
|
||||
const sent = mocks.runChatLoop.mock.calls[mocks.runChatLoop.mock.calls.length - 1][0].messages
|
||||
expect(sent[0].content).toContain('notes.md')
|
||||
expect(sent[0].content).toContain('read_file')
|
||||
expect(manager.attachedFiles.messageAttached.map((f) => f.name)).toEqual(['notes.md'])
|
||||
})
|
||||
|
||||
// A take_screenshot follow-up is a `user` message with no display counterpart
|
||||
// (appendPendingToolImages injects it). It must never become the tail
|
||||
// boundary: `messages` and `displayMessages` would then be sliced at
|
||||
@@ -2255,7 +2532,7 @@ describe('AIChatManager sendRequest lifecycle', () => {
|
||||
expect(manager.displayMessages.some((m) => m.role === 'user')).toBe(false)
|
||||
expect(manager.messages.some((m) => m.role === 'user')).toBe(false)
|
||||
// ...and its text is handed back to the composer.
|
||||
expect(restoreInstructions).toHaveBeenCalledWith('do a thing', [], [])
|
||||
expect(restoreInstructions).toHaveBeenCalledWith('do a thing', [], [], [])
|
||||
expect(manager.loading).toBe(false)
|
||||
})
|
||||
|
||||
@@ -2284,7 +2561,7 @@ describe('AIChatManager sendRequest lifecycle', () => {
|
||||
|
||||
expect(manager.displayMessages).toHaveLength(0)
|
||||
expect(manager.messages.some((m) => m.role === 'user')).toBe(false)
|
||||
expect(restoreInstructions).toHaveBeenCalledWith('do a thing', [], [])
|
||||
expect(restoreInstructions).toHaveBeenCalledWith('do a thing', [], [], [])
|
||||
expect(manager.loading).toBe(false)
|
||||
})
|
||||
|
||||
@@ -2386,7 +2663,7 @@ describe('AIChatManager sendRequest lifecycle', () => {
|
||||
expect(manager.messages.some((m) => m.role === 'assistant')).toBe(false)
|
||||
expect(manager.displayMessages.some((m) => m.role === 'assistant')).toBe(false)
|
||||
expect(manager.displayMessages.some((m) => m.role === 'user')).toBe(false)
|
||||
expect(restoreInstructions).toHaveBeenCalledWith('think hard', [], [])
|
||||
expect(restoreInstructions).toHaveBeenCalledWith('think hard', [], [], [])
|
||||
expect(manager.loading).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
import type { ContextElement } from './context'
|
||||
import { createAttachedFileContextElement, type ContextElement } from './context'
|
||||
import ToolExecutionDisplay from './ToolExecutionDisplay.svelte'
|
||||
import CompactionBoundary from './CompactionBoundary.svelte'
|
||||
import { messageDraft, segments } from './chatDraft'
|
||||
@@ -59,7 +59,7 @@
|
||||
</script>
|
||||
|
||||
{#if message.role === 'summary'}
|
||||
<CompactionBoundary content={message.content} />
|
||||
<CompactionBoundary content={message.content} files={message.files} />
|
||||
{:else}
|
||||
<div
|
||||
class={twMerge(
|
||||
@@ -74,11 +74,27 @@
|
||||
onclick={() => editMessage()}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#if message.role === 'user' && message.contextElements && editingMessageIndex !== messageIndex}
|
||||
<div class="flex flex-row gap-1 mb-1 overflow-scroll no-scrollbar px-2">
|
||||
{#each message.contextElements as element}
|
||||
<!-- One wrapping row for every badge on the message: selected context / DOM
|
||||
picks and attached files, side by side. Clicks stay inside the row: the
|
||||
wrapper's click opens edit mode, which would unmount a badge's preview
|
||||
popover the instant it opens. -->
|
||||
{#if message.role === 'user' && editingMessageIndex !== messageIndex && ((message.contextElements?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex flex-row flex-wrap gap-1 mb-1 px-2"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#each message.contextElements ?? [] as element}
|
||||
<ContextElementBadge contextElement={element} compact />
|
||||
{/each}
|
||||
<!-- Index in the key: same-named entries can survive in older transcripts. -->
|
||||
{#each message.files ?? [] as file, i (`${file.id ?? file.name}:${i}`)}
|
||||
<ContextElementBadge
|
||||
contextElement={createAttachedFileContextElement(file.name, file.content)}
|
||||
compact
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.role === 'user' && editingMessageIndex === messageIndex}
|
||||
@@ -89,6 +105,7 @@
|
||||
initialInstructions={message.content}
|
||||
initialPastes={message.pastes}
|
||||
initialImages={aiChatManager.storedImages(messageIndex)}
|
||||
initialFiles={message.files}
|
||||
{editingMessageIndex}
|
||||
onClickOutside={() => (editingMessageIndex = null)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -119,8 +136,9 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Text-free messages show only their context chips / images — no
|
||||
empty bubble (empty sends require chips or images to go out). -->
|
||||
<!-- Text-free messages show only their context chips / images / file
|
||||
chips — no empty bubble (empty sends require chips, images, or
|
||||
files to go out). -->
|
||||
{#if message.content.trim() !== ''}
|
||||
<div
|
||||
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg relative group break-words"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ChevronDown, ChevronRight, History } from 'lucide-svelte'
|
||||
import type { AttachedTextFile } from './textFileUtils'
|
||||
|
||||
let { content }: { content: string } = $props()
|
||||
let { content, files }: { content: string; files?: AttachedTextFile[] } = $props()
|
||||
|
||||
let expanded = $state(false)
|
||||
</script>
|
||||
@@ -16,7 +17,7 @@
|
||||
startIcon={{ icon: expanded ? ChevronDown : ChevronRight }}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 text-2xs text-tertiary">
|
||||
<span class="inline-flex items-center gap-1 text-normal text-2xs">
|
||||
<History size={12} />
|
||||
Summarized earlier conversation
|
||||
</span>
|
||||
@@ -28,6 +29,13 @@
|
||||
class="mt-2 max-h-80 overflow-y-auto whitespace-pre-wrap rounded-md bg-surface-secondary p-3 text-xs text-secondary"
|
||||
>
|
||||
{content}
|
||||
{#if files && files.length > 0}
|
||||
<!-- Attachments from the summarized turns, carried across the boundary
|
||||
(still tool-readable) — named here, not rendered as interactive chips. -->
|
||||
<div class="mt-2 pt-2 border-t text-2xs text-tertiary">
|
||||
Files added by the user: {files.map((f) => f.name).join(', ')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import FlowModuleIcon from '$lib/components/flows/FlowModuleIcon.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { getFileIcon } from '$lib/components/icons/fileIcon'
|
||||
|
||||
interface Props {
|
||||
contextElement: ContextElement
|
||||
@@ -33,7 +34,8 @@
|
||||
'app_dom_selector',
|
||||
'workspace_app',
|
||||
'workspace_script',
|
||||
'workspace_flow'
|
||||
'workspace_flow',
|
||||
'attached_file'
|
||||
])
|
||||
const icon = ContextIconMap[untrack(() => contextElement).type]
|
||||
let showDelete = $state(false)
|
||||
@@ -65,6 +67,10 @@
|
||||
<X size={iconSize} />
|
||||
{:else if contextElement.type === 'flow_module' || contextElement.type === 'flow_module_code_piece'}
|
||||
<FlowModuleIcon module={contextElement as FlowModule} size={iconSize} />
|
||||
{:else if contextElement.type === 'attached_file'}
|
||||
{@const fileIcon = getFileIcon(contextElement.title)}
|
||||
{@const FileIconComponent = fileIcon.icon}
|
||||
<FileIconComponent size={iconSize} class={fileIcon.className ?? ''} />
|
||||
{:else}
|
||||
{@const SvelteComponent = icon}
|
||||
<SvelteComponent size={iconSize} />
|
||||
@@ -161,6 +167,14 @@
|
||||
<div class="text-tertiary mb-1">Selected preview element</div>
|
||||
<div class="font-mono break-all">{contextElement.selector}</div>
|
||||
</div>
|
||||
{:else if contextElement.type === 'attached_file'}
|
||||
<div class="p-2 max-w-96 max-h-[300px] text-xs overflow-auto">
|
||||
<pre class="whitespace-pre-wrap break-words font-mono text-2xs"
|
||||
>{contextElement.content.length > 5000
|
||||
? contextElement.content.slice(0, 5000) + '\n…'
|
||||
: contextElement.content}</pre
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
onRemoveContext?: (contextElement: ContextElement) => void
|
||||
/** Called with image files found in a paste, so the host can attach them. */
|
||||
onImageFiles?: (files: File[]) => void
|
||||
/** Called with non-image files found in a paste, so the host can attach them. */
|
||||
onTextFiles?: (files: File[]) => void
|
||||
className?: string
|
||||
onKeyDown?: (e: KeyboardEvent) => void
|
||||
/** Rendered inside the input box, above the textarea (e.g. context chips). */
|
||||
@@ -54,6 +56,7 @@
|
||||
onAddContext,
|
||||
onRemoveContext,
|
||||
onImageFiles,
|
||||
onTextFiles,
|
||||
className = '',
|
||||
onKeyDown = undefined,
|
||||
leading
|
||||
@@ -312,13 +315,14 @@
|
||||
// a picture of them. An OS screenshot carries the image alone, so it still lands
|
||||
// here. `onImageFiles` is unset outside GLOBAL, where attaching is unsupported —
|
||||
// the paste must then fall through to text rather than be swallowed.
|
||||
if (!text.trim() && onImageFiles) {
|
||||
const imageFiles = Array.from(e.clipboardData?.files ?? []).filter((f) =>
|
||||
f.type.startsWith('image/')
|
||||
)
|
||||
if (imageFiles.length > 0) {
|
||||
if (!text.trim() && (onImageFiles || onTextFiles)) {
|
||||
const pastedFiles = Array.from(e.clipboardData?.files ?? [])
|
||||
const imageFiles = pastedFiles.filter((f) => f.type.startsWith('image/'))
|
||||
const otherFiles = pastedFiles.filter((f) => !f.type.startsWith('image/'))
|
||||
if ((imageFiles.length > 0 && onImageFiles) || (otherFiles.length > 0 && onTextFiles)) {
|
||||
e.preventDefault()
|
||||
onImageFiles(imageFiles)
|
||||
if (imageFiles.length > 0) onImageFiles?.(imageFiles)
|
||||
if (otherFiles.length > 0) onTextFiles?.(otherFiles)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,11 +487,15 @@ export default class HistoryManager {
|
||||
const existingTitle = this.savedChats[this.currentChatId]?.title
|
||||
const titleSource = displayMessages.find((m) => m.role !== 'summary') ?? displayMessages[0]
|
||||
const derivedTitle = expanded(messageDraft(titleSource)).slice(0, 50)
|
||||
// An image-only first turn has no text to derive from — fall back to the
|
||||
// attachment's filename so the History menu entry isn't blank.
|
||||
// An attachment-only first turn has no text to derive from — fall back to
|
||||
// the attachment's filename so the History menu entry isn't blank.
|
||||
const imageFallback =
|
||||
titleSource.role === 'user' && titleSource.images?.length
|
||||
? (titleSource.images[0].name ?? 'Image attachment')
|
||||
titleSource.role === 'user'
|
||||
? titleSource.images?.length
|
||||
? (titleSource.images[0].name ?? 'Image attachment')
|
||||
: titleSource.files?.length
|
||||
? titleSource.files[0].name
|
||||
: ''
|
||||
: ''
|
||||
// A hydrated omission marker is not user text — deriving from it would
|
||||
// overwrite the filename title an evicted image-only chat was given.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { X } from 'lucide-svelte'
|
||||
import { FileText, X } from 'lucide-svelte'
|
||||
import ContextElementBadge from './ContextElementBadge.svelte'
|
||||
import { contextElementKey } from './context'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
@@ -13,12 +13,13 @@
|
||||
const aiChatManager = getAiChatManager()
|
||||
</script>
|
||||
|
||||
<!-- Image-only and context-only queues have empty text; without their image /
|
||||
badge row the queued draft would be invisible — undismissable, then
|
||||
auto-sent as a surprise turn. Badges render here only for context-ONLY
|
||||
queues: text queues pin the same chips, but those stay visible in the
|
||||
composer, and repeating them would read as two selections. -->
|
||||
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || (aiChatManager.queuedContext?.length ?? 0) > 0}
|
||||
<!-- Attachment-only and context-only queues have empty text; without their
|
||||
image / file / badge row the queued draft would be invisible —
|
||||
undismissable, then auto-sent as a surprise turn. Context badges render
|
||||
here only for context-ONLY queues: text queues pin the same chips, but
|
||||
those stay visible in the composer, and repeating them would read as two
|
||||
selections. -->
|
||||
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0 || (aiChatManager.queuedContext?.length ?? 0) > 0}
|
||||
<div
|
||||
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60"
|
||||
title={aiChatManager.queuedMessage}
|
||||
@@ -35,11 +36,24 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if aiChatManager.queuedFiles.length > 0}
|
||||
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
|
||||
{#each aiChatManager.queuedFiles as file, i (i)}
|
||||
<span
|
||||
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
|
||||
title={file.name}
|
||||
>
|
||||
<FileText size={10} class="shrink-0" />
|
||||
<span class="truncate min-w-0">{file.name}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if aiChatManager.queuedMessage}
|
||||
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
|
||||
{aiChatManager.queuedMessage}
|
||||
</p>
|
||||
{:else if aiChatManager.queuedImages.length === 0 && aiChatManager.queuedContext?.length}
|
||||
{:else if aiChatManager.queuedImages.length === 0 && aiChatManager.queuedFiles.length === 0 && aiChatManager.queuedContext?.length}
|
||||
<div class="flex flex-row flex-wrap gap-1">
|
||||
{#each aiChatManager.queuedContext as element (contextElementKey(element))}
|
||||
<ContextElementBadge contextElement={element} compact />
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
TriangleAlert,
|
||||
Diff,
|
||||
FileCode,
|
||||
FileText,
|
||||
Code2,
|
||||
TextSelect,
|
||||
Table2,
|
||||
@@ -29,7 +30,9 @@ export const ContextIconMap = {
|
||||
app_dom_selector: MousePointer2,
|
||||
workspace_script: Code2,
|
||||
workspace_flow: BarsStaggered,
|
||||
workspace_app: LayoutDashboard
|
||||
workspace_app: LayoutDashboard,
|
||||
// Fallback — the badge renders attached_file with the per-extension file icon.
|
||||
attached_file: FileText
|
||||
// flow_module type is handled with FlowModuleIcon
|
||||
}
|
||||
|
||||
@@ -283,6 +286,22 @@ export interface WorkspaceAppElement {
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/** Message-attached text file, rendered with the shared context badge. Never part
|
||||
* of `selectedContext` — constructed at render time from the composer's / a sent
|
||||
* message's `files` state (see createAttachedFileContextElement). */
|
||||
export interface AttachedFileElement {
|
||||
type: 'attached_file'
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export function createAttachedFileContextElement(
|
||||
name: string,
|
||||
content: string
|
||||
): AttachedFileElement {
|
||||
return { type: 'attached_file', title: name, content }
|
||||
}
|
||||
|
||||
export type ContextElement = (
|
||||
| CodeElement
|
||||
| ErrorElement
|
||||
@@ -299,6 +318,7 @@ export type ContextElement = (
|
||||
| WorkspaceScriptElement
|
||||
| WorkspaceFlowElement
|
||||
| WorkspaceAppElement
|
||||
| AttachedFileElement
|
||||
) & {
|
||||
deletable?: boolean
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#if cards.length > 0}
|
||||
<div class="flex flex-row flex-nowrap items-center gap-1 mt-1 mb-1 min-w-0" role="list">
|
||||
<div class="flex flex-row flex-nowrap items-center gap-1 min-w-0" role="list">
|
||||
{#each visible as card (card.key)}
|
||||
{@render chip(card)}
|
||||
{/each}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ensurePersistentStorage,
|
||||
type PersistedAttachedItem
|
||||
} from './attachedFilesDB'
|
||||
import { sanitizeAttachmentName } from '../textFileUtils'
|
||||
import { enumerateDir, isIgnoredPath, queryReadPermission, requestReadPermission } from './fsAccess'
|
||||
|
||||
export type AttachedFileStatus = 'indexing' | 'ready' | 'error' | 'locked' | 'unavailable'
|
||||
@@ -45,6 +46,19 @@ export interface AttachedFile extends FileEntry {
|
||||
* (locked/unavailable). Consumers should read `store.folders` instead of testing this.
|
||||
*/
|
||||
isFolderRoot?: boolean
|
||||
/** Attached to a chat message: readable via the file tools like any other row,
|
||||
* but hidden from the session footer bar (its chip lives on the message). */
|
||||
messageScoped?: boolean
|
||||
/** Stable content-hash id (message-scoped rows only) — the reference the
|
||||
* transcript and prompt carry. Names are display-only and may collide;
|
||||
* lookups join on this. See attachedTextFileId. */
|
||||
id?: string
|
||||
/** Raw pre-sanitization source name (session rows) — the re-link identity.
|
||||
* Display names lose information (sanitize + suffix), so a re-link must
|
||||
* match on the raw name it arrives under: two distinct raw names that
|
||||
* sanitize identically are different files even with equal stats. Falls back
|
||||
* to `name` for rows persisted before provenance existed. */
|
||||
sourceName?: string
|
||||
}
|
||||
|
||||
/** A linked folder as a first-class object — consumers read this instead of re-grouping rows. */
|
||||
@@ -87,8 +101,31 @@ export class AttachedFilesStore {
|
||||
return this.files
|
||||
}
|
||||
get(name: string): AttachedFile | undefined {
|
||||
// Resolve to a real file — a folder-root placeholder may share the folder's name.
|
||||
return this.files.find((f) => f.name === name && !f.isFolderRoot)
|
||||
// Name lookup. A bare name is the roster's namespace: session links are
|
||||
// advertised by filename and have no other handle, so they resolve first —
|
||||
// a same-named message attachment must not shadow them (it is addressed by
|
||||
// id). Message rows resolve by name only as the fallback, for transcripts
|
||||
// persisted before ids existed.
|
||||
// Folder-root placeholders may share a name with a real file — never resolve to one.
|
||||
return (
|
||||
this.files.find((f) => f.name === name && !f.isFolderRoot && !f.messageScoped) ??
|
||||
this.files.find((f) => f.name === name && f.messageScoped)
|
||||
)
|
||||
}
|
||||
/** Resolve a tool-supplied file reference: stable id first (how message
|
||||
* attachments are addressed), then name (session links + legacy prompts).
|
||||
* The composite label the roster and search hits print — `name (file id: x)`
|
||||
* — resolves too: models echo references verbatim, so the printed form must
|
||||
* be a valid one. Exact matches win over label interpretation: a row whose
|
||||
* literal filename happens to look like a label stays addressable by it. */
|
||||
resolve(ref: string): AttachedFile | undefined {
|
||||
const exact = this.files.find((f) => f.id === ref) ?? this.get(ref)
|
||||
if (exact) return exact
|
||||
const label = ref.match(/^(.*) \(file id: ([^)]+)\)$/)
|
||||
if (label) {
|
||||
return this.files.find((f) => f.id === label[2]) ?? this.get(label[1])
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
readyFiles(): AttachedFile[] {
|
||||
// Folder-root placeholders aren't real files — never expose them to the read/search tools.
|
||||
@@ -114,8 +151,15 @@ export class AttachedFilesStore {
|
||||
}))
|
||||
})
|
||||
|
||||
/** Files linked on their own (not as part of a folder). */
|
||||
standalone: AttachedFile[] = $derived.by(() => this.files.filter((f) => !f.folder))
|
||||
/** Files linked on their own (not as part of a folder or a message). */
|
||||
standalone: AttachedFile[] = $derived.by(() =>
|
||||
this.files.filter((f) => !f.folder && !f.messageScoped)
|
||||
)
|
||||
|
||||
/** Files attached to chat messages — tool-readable, not shown in the footer bar. */
|
||||
messageAttached: AttachedFile[] = $derived.by(() =>
|
||||
this.files.filter((f) => !f.folder && f.messageScoped)
|
||||
)
|
||||
|
||||
/** Number of locked folders needing a re-grant. */
|
||||
get lockedCount(): number {
|
||||
@@ -128,15 +172,57 @@ export class AttachedFilesStore {
|
||||
}
|
||||
|
||||
removeFile(name: string): void {
|
||||
// Session rows only: message-scoped rows are managed by syncMessageScoped, and
|
||||
// a footer chip removal must never take a same-named message attachment with it.
|
||||
// Target the real file only — never a folder-root placeholder that happens to share
|
||||
// the name (those are managed via removeFolder), else removing a same-named standalone
|
||||
// file would also drop the folder's placeholder.
|
||||
const f = this.files.find((x) => x.name === name && !x.isFolderRoot)
|
||||
const f = this.files.find((x) => x.name === name && !x.isFolderRoot && !x.messageScoped)
|
||||
if (!f) return
|
||||
this.files = this.files.filter((x) => !(x.name === name && !x.isFolderRoot))
|
||||
this.files = this.files.filter((x) => x !== f)
|
||||
void this.#deleteRecord(f.sourceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile message-scoped rows to exactly `wanted` — the union of files the
|
||||
* current transcript references, joined on the stable id. The transcript is
|
||||
* their durable home: rows are rebuilt from it on chat load and pruned when a
|
||||
* rollback or an edit/retry truncation drops the message that carried them.
|
||||
* Synchronous — decisions compare ids, never content — so rapid chat
|
||||
* switching cannot interleave two reconciliations.
|
||||
*/
|
||||
syncMessageScoped(wanted: { name: string; content: string; id: string }[]): void {
|
||||
const wantedIds = new Set(wanted.map((f) => f.id))
|
||||
for (const f of this.files.filter((x) => x.messageScoped)) {
|
||||
if (!f.id || !wantedIds.has(f.id)) {
|
||||
this.files = this.files.filter((x) => x !== f)
|
||||
void this.#deleteRecord(f.sourceId)
|
||||
}
|
||||
}
|
||||
this.registerMessageFiles(wanted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a message's attachments as tool-readable rows. Identity is the
|
||||
* content-hash id: a row already holding the id is reused (retry, sync
|
||||
* rebuild), and distinct files register independently even under one display
|
||||
* name. Rows are NOT persisted here — their durable home is the chat
|
||||
* transcript (DisplayMessage.files), from which syncMessageScoped rebuilds
|
||||
* them on load; a second copy in IndexedDB would only drift.
|
||||
*/
|
||||
registerMessageFiles(files: { name: string; content: string; id: string }[]): void {
|
||||
for (const f of files) {
|
||||
if (this.files.some((x) => x.messageScoped && x.id === f.id)) continue
|
||||
this.#pushIndexing({
|
||||
name: f.name,
|
||||
file: new File([f.content], f.name, { type: 'text/plain' }),
|
||||
sourceId: f.id,
|
||||
messageScoped: true,
|
||||
id: f.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove every file linked as part of the given folder (and its persisted record). */
|
||||
removeFolder(folder: string): void {
|
||||
const ids = new Set(this.files.filter((f) => f.folder === folder).map((f) => f.sourceId))
|
||||
@@ -157,34 +243,40 @@ export class AttachedFilesStore {
|
||||
|
||||
for (const item of Array.from(input as ArrayLike<FileToAttach>)) {
|
||||
const file = item instanceof File ? item : item.file
|
||||
const desired =
|
||||
// rawPath keys everything that must match the disk (folder split, junk
|
||||
// filter, relPath); the sanitized form is what names may be compared and
|
||||
// stored as — mixing the two is how dedupe once compared raw against
|
||||
// sanitized and missed.
|
||||
const rawPath =
|
||||
(item instanceof File ? '' : (item.path ?? '')) ||
|
||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
||||
file.name ||
|
||||
'file'
|
||||
const folder = rawPath.includes('/') ? rawPath.split('/')[0] : undefined
|
||||
if (folder && isIgnoredPath(rawPath)) continue // skip junk inside folders
|
||||
|
||||
const folder = desired.includes('/') ? desired.split('/')[0] : undefined
|
||||
if (folder && isIgnoredPath(desired)) continue // skip junk inside folders
|
||||
|
||||
if (this.#isDuplicate(desired, file)) continue // silent no-op on re-link
|
||||
if (this.#isDuplicate(rawPath, file)) {
|
||||
continue // silent no-op on re-link
|
||||
}
|
||||
|
||||
const reason = await this.#preflight(file)
|
||||
if (reason) {
|
||||
result.rejected.push({ name: desired, reason })
|
||||
result.rejected.push({ name: sanitizeAttachmentName(rawPath), reason })
|
||||
continue
|
||||
}
|
||||
|
||||
const name = this.#uniqueName(desired)
|
||||
const relPath = folder ? desired : undefined
|
||||
const name = this.#claimName(rawPath)
|
||||
const relPath = folder ? rawPath : undefined
|
||||
const sourceId = createLongHash()
|
||||
|
||||
this.#pushIndexing({ name, file, folder, sourceId, relPath })
|
||||
this.#pushIndexing({ name, sourceName: rawPath, file, folder, sourceId, relPath })
|
||||
result.added.push(name)
|
||||
void this.#persist({
|
||||
id: sourceId,
|
||||
sessionId: this.sessionId ?? '',
|
||||
kind: 'snapshot',
|
||||
name,
|
||||
sourceName: rawPath,
|
||||
folder,
|
||||
relPath,
|
||||
blob: file,
|
||||
@@ -227,8 +319,16 @@ export class AttachedFilesStore {
|
||||
result.rejected.push({ name: path, reason: 'Not a text file' })
|
||||
continue
|
||||
}
|
||||
const name = this.#uniqueName(path)
|
||||
this.#pushIndexing({ name, file, folder, sourceId, handle: dirHandle, relPath: path })
|
||||
const name = this.#claimName(path)
|
||||
this.#pushIndexing({
|
||||
name,
|
||||
sourceName: path,
|
||||
file,
|
||||
folder,
|
||||
sourceId,
|
||||
handle: dirHandle,
|
||||
relPath: path
|
||||
})
|
||||
result.added.push(name)
|
||||
}
|
||||
// Keep the folder represented even when it links empty (or all-binary): a placeholder
|
||||
@@ -264,8 +364,12 @@ export class AttachedFilesStore {
|
||||
this.#pushPlaceholder(item, 'unavailable')
|
||||
continue
|
||||
}
|
||||
// Claimed on read from the persisted RAW identity: legacy names can
|
||||
// sanitize to one display name (both must stay resolvable), and the
|
||||
// raw sourceName is what a later re-link dedupes against.
|
||||
this.#pushIndexing({
|
||||
name: item.name,
|
||||
name: this.#claimName(item.sourceName ?? item.name),
|
||||
sourceName: item.sourceName ?? item.name,
|
||||
file: item.blob,
|
||||
folder: item.folder,
|
||||
relPath: item.relPath,
|
||||
@@ -367,22 +471,28 @@ export class AttachedFilesStore {
|
||||
|
||||
// ------------------------------------------------------------- internals
|
||||
|
||||
/** Identical re-link (same name, or same File identity) → silent no-op. */
|
||||
#isDuplicate(desired: string, file: File): boolean {
|
||||
/** Identical session-file re-link (same name, or same File identity) → silent no-op.
|
||||
* Session rows only — message rows dedupe by their content-hash id in
|
||||
* registerMessageFiles, and a same-named row of the other scope is a
|
||||
* different file, not a duplicate. */
|
||||
#isDuplicate(rawPath: string, file: File): boolean {
|
||||
// "Duplicate" means the SAME source file re-linked: identical stats plus a
|
||||
// matching RAW identity — the row's sourceName (raw name at link time,
|
||||
// `name` fallback for pre-provenance rows) or, for folder children, the
|
||||
// raw on-disk relPath. Never a display-name compare: sanitize and suffix
|
||||
// both lose information, so two distinct raw names can share a display
|
||||
// name even with equal stats and must both survive (#claimName suffixes
|
||||
// the second).
|
||||
return this.files.some(
|
||||
(f) =>
|
||||
// Folder-root placeholders aren't real files — they must not block attaching a
|
||||
// standalone file that happens to share the folder's name.
|
||||
!f.isFolderRoot &&
|
||||
(f.name === desired ||
|
||||
// Identical re-drop at the SAME relative path (its row name may have been
|
||||
// auto-suffixed). Keyed on the path, NOT the basename — otherwise two distinct
|
||||
// files sharing a basename under different folder subdirs (proj/a/index.ts vs
|
||||
// proj/b/index.ts) would be wrongly deduped and silently dropped.
|
||||
((f.relPath ?? f.name) === desired &&
|
||||
f.size === file.size &&
|
||||
f.file instanceof File &&
|
||||
f.file.lastModified === file.lastModified))
|
||||
!f.messageScoped &&
|
||||
f.size === file.size &&
|
||||
f.file instanceof File &&
|
||||
f.file.lastModified === file.lastModified &&
|
||||
((f.sourceName ?? f.name) === rawPath || f.relPath === rawPath)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -402,16 +512,20 @@ export class AttachedFilesStore {
|
||||
|
||||
#pushIndexing(p: {
|
||||
name: string
|
||||
sourceName?: string
|
||||
file: File | Blob
|
||||
folder?: string
|
||||
sourceId: string
|
||||
handle?: FileSystemDirectoryHandle
|
||||
relPath?: string
|
||||
messageScoped?: boolean
|
||||
id?: string
|
||||
}): void {
|
||||
this.files = [
|
||||
...this.files,
|
||||
{
|
||||
name: p.name,
|
||||
sourceName: p.sourceName,
|
||||
file: p.file,
|
||||
size: p.file.size,
|
||||
lineIndex: [],
|
||||
@@ -420,7 +534,9 @@ export class AttachedFilesStore {
|
||||
folder: p.folder,
|
||||
sourceId: p.sourceId,
|
||||
handle: p.handle,
|
||||
relPath: p.relPath
|
||||
relPath: p.relPath,
|
||||
messageScoped: p.messageScoped,
|
||||
id: p.id
|
||||
}
|
||||
]
|
||||
void this.#indexFile(p.name, p.file)
|
||||
@@ -431,10 +547,18 @@ export class AttachedFilesStore {
|
||||
status: AttachedFileStatus,
|
||||
isFolderRoot = false
|
||||
): void {
|
||||
// File placeholders claim like any row (a legacy name may collide once
|
||||
// sanitized); folder-root placeholders keep the folder key's name and stay
|
||||
// outside name uniqueness — they may legitimately share a name with a file.
|
||||
const name = isFolderRoot
|
||||
? sanitizeAttachmentName(item.name)
|
||||
: this.#claimName(item.sourceName ?? item.name)
|
||||
const sourceName = isFolderRoot ? undefined : (item.sourceName ?? item.name)
|
||||
this.files = [
|
||||
...this.files,
|
||||
{
|
||||
name: item.name,
|
||||
name,
|
||||
sourceName,
|
||||
file: EMPTY,
|
||||
size: item.size ?? 0,
|
||||
lineIndex: [],
|
||||
@@ -453,8 +577,16 @@ export class AttachedFilesStore {
|
||||
const children = await enumerateDir(dirHandle)
|
||||
for (const { file, path } of children) {
|
||||
if (!(await this.#sniffText(file))) continue
|
||||
const name = this.#uniqueName(path)
|
||||
this.#pushIndexing({ name, file, folder, sourceId, handle: dirHandle, relPath: path })
|
||||
const name = this.#claimName(path)
|
||||
this.#pushIndexing({
|
||||
name,
|
||||
sourceName: path,
|
||||
file,
|
||||
folder,
|
||||
sourceId,
|
||||
handle: dirHandle,
|
||||
relPath: path
|
||||
})
|
||||
}
|
||||
this.#ensureFolderRow(sourceId, folder, dirHandle)
|
||||
}
|
||||
@@ -501,22 +633,33 @@ export class AttachedFilesStore {
|
||||
if (!cur) {
|
||||
// newly added on disk
|
||||
if (!(await this.#sniffText(file))) continue
|
||||
const name = this.#uniqueName(path)
|
||||
this.#pushIndexing({ name, file, folder, sourceId, handle, relPath: path })
|
||||
const name = this.#claimName(path)
|
||||
this.#pushIndexing({
|
||||
name,
|
||||
sourceName: path,
|
||||
file,
|
||||
folder,
|
||||
sourceId,
|
||||
handle,
|
||||
relPath: path
|
||||
})
|
||||
} else {
|
||||
const curMod = cur.file instanceof File ? cur.file.lastModified : undefined
|
||||
if (file.size !== cur.size || file.lastModified !== curMod) {
|
||||
// content changed → re-read + re-index
|
||||
this.#patch(cur.name, { file, size: file.size, status: 'indexing' })
|
||||
// content changed → re-read + re-index. Patch by row identity — a
|
||||
// message attachment may share the display name.
|
||||
this.files = this.files.map((f) =>
|
||||
f === cur ? { ...f, file, size: file.size, status: 'indexing' } : f
|
||||
)
|
||||
void this.#indexFile(cur.name, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
// removed/renamed-away on disk → drop from memory
|
||||
const removed = [...existing.values()].filter((f) => f.relPath && !seen.has(f.relPath))
|
||||
if (removed.length > 0) {
|
||||
const names = new Set(removed.map((f) => f.name))
|
||||
this.files = this.files.filter((f) => !names.has(f.name))
|
||||
// removed/renamed-away on disk → drop from memory (by row identity — a
|
||||
// message attachment may share the display name and must survive)
|
||||
const removed = new Set([...existing.values()].filter((f) => f.relPath && !seen.has(f.relPath)))
|
||||
if (removed.size > 0) {
|
||||
this.files = this.files.filter((f) => !removed.has(f))
|
||||
}
|
||||
this.#ensureFolderRow(sourceId, folder, handle)
|
||||
}
|
||||
@@ -563,9 +706,6 @@ export class AttachedFilesStore {
|
||||
}
|
||||
}
|
||||
|
||||
#patch(name: string, changes: Partial<AttachedFile>): void {
|
||||
this.files = this.files.map((f) => (f.name === name ? { ...f, ...changes } : f))
|
||||
}
|
||||
/**
|
||||
* Patch the row for `name` ONLY while it still holds the exact `file` we indexed.
|
||||
* `buildLineIndex` is async and unawaited; between its start and finish the row's
|
||||
@@ -583,10 +723,22 @@ export class AttachedFilesStore {
|
||||
this.files = this.files.map((f) => (f.sourceId === sourceId ? { ...f, ...changes } : f))
|
||||
}
|
||||
|
||||
/** The only way a session row gets its display name: sanitize (control
|
||||
* characters must never reach model-facing text) then uniquify among session
|
||||
* rows. Every creation path — attach, folder expansion, refresh, persisted
|
||||
* restore — goes through here so none can skip a rule. Message rows stay out
|
||||
* by design (id-addressed, names may collide); `relPath`, `folder`, and the
|
||||
* row's `sourceName` (re-link identity) stay raw by design. */
|
||||
#claimName(raw: string): string {
|
||||
return this.#uniqueName(sanitizeAttachmentName(raw))
|
||||
}
|
||||
|
||||
#uniqueName(original: string): string {
|
||||
// Uniqueness is only among real files — folder-root placeholders may share a name
|
||||
// with a standalone file and must not push it to a "(2)" suffix.
|
||||
const taken = (n: string) => this.files.some((f) => f.name === n && !f.isFolderRoot)
|
||||
// Uniqueness is only among session rows: folder-root placeholders aren't real
|
||||
// files, and message rows are addressed by id — their display names neither
|
||||
// block a session link nor need protecting from one.
|
||||
const taken = (n: string) =>
|
||||
this.files.some((f) => f.name === n && !f.isFolderRoot && !f.messageScoped)
|
||||
if (!taken(original)) return original
|
||||
const dot = original.lastIndexOf('.')
|
||||
const base = dot > 0 ? original.slice(0, dot) : original
|
||||
|
||||
@@ -34,11 +34,17 @@ vi.mock('./fileEngine', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { AttachedFilesStore } from './attachedFiles.svelte'
|
||||
import { attachedTextFileId } from '../textFileUtils'
|
||||
|
||||
function file(name: string, content: string, lastModified = 1): File {
|
||||
return new File([content], name, { type: 'text/plain', lastModified })
|
||||
}
|
||||
|
||||
/** A message attachment as the transcript carries it: name + content + stable id. */
|
||||
function mf(name: string, content: string): { name: string; content: string; id: string } {
|
||||
return { name, content, id: attachedTextFileId(name, content) }
|
||||
}
|
||||
|
||||
const dir = { kind: 'directory', name: 'proj' } as unknown as FileSystemDirectoryHandle
|
||||
|
||||
async function settle(store: AttachedFilesStore) {
|
||||
@@ -401,6 +407,31 @@ describe('AttachedFilesStore', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a session row indexable when a same-named message file registers mid-index', async () => {
|
||||
buildMode = 'manual'
|
||||
try {
|
||||
const S = file('notes.md', 'session\n')
|
||||
await store.addFiles([S]) // session row 'notes.md' (file S) → index pending
|
||||
|
||||
// A same-named message attachment rebuilds while S is still indexing —
|
||||
// neither row is renamed, and neither index is stranded.
|
||||
store.syncMessageScoped([mf('notes.md', 'message\n')])
|
||||
|
||||
for (const d of [...buildDeferreds]) d.resolve({ lineIndex: [0], lineCount: 1 })
|
||||
await settle(store)
|
||||
|
||||
// Both rows ready: the message row via its id, the session row via name.
|
||||
const messageRow = store.resolve(attachedTextFileId('notes.md', 'message\n'))
|
||||
expect(messageRow?.messageScoped).toBe(true)
|
||||
expect(messageRow?.status).toBe('ready')
|
||||
const sessionRow = store.files.find((f) => f.name === 'notes.md' && !f.messageScoped)
|
||||
expect(sessionRow?.status).toBe('ready')
|
||||
} finally {
|
||||
buildMode = 'real'
|
||||
buildDeferreds.length = 0
|
||||
}
|
||||
})
|
||||
|
||||
it('removeFolder deletes the live folder record from storage (persisted session)', async () => {
|
||||
const { deleteItem } = await import('./attachedFilesDB')
|
||||
const s = new AttachedFilesStore()
|
||||
@@ -473,4 +504,204 @@ describe('AttachedFilesStore', () => {
|
||||
await settle(s)
|
||||
expect(s.folders[0].files.map((f) => f.relPath)).toEqual(['proj/app.ts'])
|
||||
})
|
||||
|
||||
it('a session link and a message attachment share a name independently', async () => {
|
||||
await store.addFiles([file('notes.md', 'session content\n')])
|
||||
await settle(store)
|
||||
|
||||
store.registerMessageFiles([mf('notes.md', 'message content\n')])
|
||||
await settle(store)
|
||||
|
||||
// Two rows, one display name, independently addressable: the message row by
|
||||
// its id, the session row by name (its roster handle — a bare-name resolve
|
||||
// must return it, never the same-named message attachment shadowing it).
|
||||
const messageRow = store.resolve(attachedTextFileId('notes.md', 'message content\n'))
|
||||
expect(messageRow?.messageScoped).toBe(true)
|
||||
expect(await (messageRow!.file as Blob).text()).toBe('message content\n')
|
||||
const sessionRow = store.resolve('notes.md')
|
||||
expect(sessionRow?.messageScoped).toBeFalsy()
|
||||
expect(await (sessionRow!.file as Blob).text()).toBe('session content\n')
|
||||
|
||||
// A footer removal of the session file must not take the message row along.
|
||||
store.removeFile('notes.md')
|
||||
expect(store.resolve(messageRow!.id!)?.messageScoped).toBe(true)
|
||||
expect(store.standalone).toEqual([])
|
||||
})
|
||||
|
||||
it('resolve falls back to a name lookup for legacy references', async () => {
|
||||
// Chats persisted before ids existed reference message files by bare name.
|
||||
store.registerMessageFiles([mf('notes.md', 'message content\n')])
|
||||
await settle(store)
|
||||
expect(store.resolve('notes.md')?.messageScoped).toBe(true)
|
||||
expect(await (store.resolve('notes.md')!.file as Blob).text()).toBe('message content\n')
|
||||
})
|
||||
|
||||
it('resolve accepts the printed composite label verbatim', async () => {
|
||||
// Rosters and search hits print `name (file id: x)`; models echo references
|
||||
// verbatim, so the printed form must resolve.
|
||||
const f = mf('notes.md', 'message content\n')
|
||||
store.registerMessageFiles([f])
|
||||
await settle(store)
|
||||
expect(store.resolve(`notes.md (file id: ${f.id})`)?.id).toBe(f.id)
|
||||
})
|
||||
|
||||
it('stores control-char filenames sanitized so the advertised name resolves', async () => {
|
||||
// The roster prints sanitized names; the stored name must BE that name or
|
||||
// the reference shown to the model would not resolve.
|
||||
await store.addFiles([file('a\nb.md', 'controlled\n')])
|
||||
await settle(store)
|
||||
expect(store.get('a b.md')?.status).toBe('ready')
|
||||
expect(store.resolve('a b.md')).toBeDefined()
|
||||
expect(store.list().some((f) => f.name.includes('\n'))).toBe(false)
|
||||
})
|
||||
|
||||
it('re-linking a control-char filename dedupes against the sanitized stored name', async () => {
|
||||
await store.addFiles([file('a\nb.md', 'controlled\n')])
|
||||
await settle(store)
|
||||
// The duplicate check must compare what is STORED (sanitized), not the raw
|
||||
// re-link name — else every control-char file re-links as a "(2)" copy.
|
||||
await store.addFiles([file('a\nb.md', 'controlled\n')])
|
||||
await settle(store)
|
||||
expect(store.standalone.map((f) => f.name)).toEqual(['a b.md'])
|
||||
})
|
||||
|
||||
it('links two distinct files whose raw names sanitize identically — equal stats included', async () => {
|
||||
// A display-name collision is NOT a duplicate: the re-link identity is the
|
||||
// RAW name, so even identical size and mtime cannot collapse two distinct
|
||||
// raw names into one row. The second claims a suffixed name.
|
||||
await store.addFiles([file('a\nb.md', 'first\n', 1)])
|
||||
await settle(store)
|
||||
await store.addFiles([file('a\tb.md', 'other\n', 1)]) // same size, same mtime
|
||||
await settle(store)
|
||||
expect(store.standalone.map((f) => f.name).sort()).toEqual(['a b (2).md', 'a b.md'])
|
||||
|
||||
// Re-link of the suffixed file matches via its raw sourceName.
|
||||
await store.addFiles([file('a\tb.md', 'other\n', 1)])
|
||||
await settle(store)
|
||||
expect(store.standalone).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('re-link dedupe survives a reload — raw provenance is persisted', async () => {
|
||||
const { getItemsForSession } = await import('./attachedFilesDB')
|
||||
const record = (id: string, name: string, sourceName: string, blob: File) => ({
|
||||
id,
|
||||
sessionId: 's1',
|
||||
kind: 'snapshot' as const,
|
||||
name,
|
||||
sourceName,
|
||||
blob,
|
||||
size: blob.size,
|
||||
lastModified: blob.lastModified,
|
||||
addedAt: 1
|
||||
})
|
||||
;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
record('r1', 'a b.md', 'a\nb.md', file('a b.md', 'first\n', 1)),
|
||||
record('r2', 'a b (2).md', 'a\tb.md', file('a b (2).md', 'other\n', 2))
|
||||
])
|
||||
const s = new AttachedFilesStore()
|
||||
await s.restore('s1', false)
|
||||
await settle(s)
|
||||
expect(s.standalone.map((f) => f.name).sort()).toEqual(['a b (2).md', 'a b.md'])
|
||||
|
||||
// Re-linking the original file behind the suffixed row must dedupe, not
|
||||
// create `a b (3).md` — the raw identity rode the persisted record.
|
||||
await s.addFiles([file('a\tb.md', 'other\n', 2)])
|
||||
await settle(s)
|
||||
expect(s.standalone).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('restore claims names, keeping legacy rows that sanitize identically distinct', async () => {
|
||||
const { getItemsForSession } = await import('./attachedFilesDB')
|
||||
const record = (id: string, name: string) => ({
|
||||
id,
|
||||
sessionId: 's1',
|
||||
kind: 'snapshot' as const,
|
||||
name,
|
||||
blob: new Blob([`content ${id}\n`]),
|
||||
size: 10,
|
||||
lastModified: 1,
|
||||
addedAt: 1
|
||||
})
|
||||
;(getItemsForSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
record('r1', 'a\nb.md'),
|
||||
record('r2', 'a\tb.md')
|
||||
])
|
||||
const s = new AttachedFilesStore()
|
||||
await s.restore('s1', false)
|
||||
await settle(s)
|
||||
// Both legacy rows sanitize to `a b.md`; the claim must uniquify so each
|
||||
// stays independently visible and resolvable.
|
||||
expect(s.standalone.map((f) => f.name).sort()).toEqual(['a b (2).md', 'a b.md'])
|
||||
expect(s.resolve('a b.md')).toBeDefined()
|
||||
expect(s.resolve('a b (2).md')).toBeDefined()
|
||||
})
|
||||
|
||||
it('resolve prefers a literal filename over label interpretation', async () => {
|
||||
// A file literally named like a printed label must stay addressable by its
|
||||
// exact name — label parsing must not strip it down to the base name.
|
||||
await store.addFiles([file('notes (file id: missing)', 'literal\n'), file('notes', 'base\n')])
|
||||
await settle(store)
|
||||
expect(await (store.resolve('notes (file id: missing)')!.file as Blob).text()).toBe('literal\n')
|
||||
})
|
||||
|
||||
it('syncMessageScoped reconciles rows to the transcript references', async () => {
|
||||
store.syncMessageScoped([mf('a.md', 'aaa\n'), mf('b.md', 'bbb\n')])
|
||||
await settle(store)
|
||||
expect(store.messageAttached.map((f) => f.name).sort()).toEqual(['a.md', 'b.md'])
|
||||
|
||||
// A message dropped from the transcript prunes its row; the survivor stays.
|
||||
store.syncMessageScoped([mf('a.md', 'aaa\n')])
|
||||
await settle(store)
|
||||
expect(store.messageAttached.map((f) => f.name)).toEqual(['a.md'])
|
||||
|
||||
store.syncMessageScoped([])
|
||||
expect(store.messageAttached).toEqual([])
|
||||
})
|
||||
|
||||
it('back-to-back reconciliations commit only the latest set', async () => {
|
||||
// Rapid chat switching fires syncs in quick succession; reconciliation is
|
||||
// synchronous, so the last call's set simply wins.
|
||||
store.syncMessageScoped([mf('old.md', 'OLD sentinel\n')])
|
||||
store.syncMessageScoped([mf('new.md', 'NEW sentinel\n')])
|
||||
await settle(store)
|
||||
expect(store.messageAttached.map((f) => f.name)).toEqual(['new.md'])
|
||||
})
|
||||
|
||||
it('syncMessageScoped replaces a stale row under the same name', async () => {
|
||||
store.syncMessageScoped([mf('a.md', 'old\n')])
|
||||
await settle(store)
|
||||
|
||||
// The transcript's copy changed (edited message): different content means a
|
||||
// different id, so the stale row is pruned and the new one registered.
|
||||
store.syncMessageScoped([mf('a.md', 'new\n')])
|
||||
await settle(store)
|
||||
expect(store.messageAttached.map((f) => f.name)).toEqual(['a.md'])
|
||||
expect(await (store.get('a.md')!.file as Blob).text()).toBe('new\n')
|
||||
})
|
||||
|
||||
it('keeps message-scoped files tool-readable but out of the footer roster', async () => {
|
||||
store.registerMessageFiles([mf('notes.md', 'hello\n')])
|
||||
await settle(store)
|
||||
|
||||
// Hidden from the session bar, listed for the tools, readable by id.
|
||||
expect(store.standalone).toEqual([])
|
||||
expect(store.messageAttached.map((f) => f.name)).toEqual(['notes.md'])
|
||||
expect(store.resolve(attachedTextFileId('notes.md', 'hello\n'))?.status).toBe('ready')
|
||||
|
||||
// Identical re-registration (retry / sync rebuild) reuses the row — same id.
|
||||
store.registerMessageFiles([mf('notes.md', 'hello\n')])
|
||||
expect(store.messageAttached.map((f) => f.name)).toEqual(['notes.md'])
|
||||
|
||||
// A same-named file with DIFFERENT content is another message's attachment:
|
||||
// distinct id, its own row, both independently readable under one label.
|
||||
store.registerMessageFiles([mf('notes.md', 'other\n')])
|
||||
await settle(store)
|
||||
expect(store.messageAttached.map((f) => f.name)).toEqual(['notes.md', 'notes.md'])
|
||||
expect(
|
||||
await (store.resolve(attachedTextFileId('notes.md', 'other\n'))!.file as Blob).text()
|
||||
).toBe('other\n')
|
||||
expect(
|
||||
await (store.resolve(attachedTextFileId('notes.md', 'hello\n'))!.file as Blob).text()
|
||||
).toBe('hello\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface PersistedAttachedItem {
|
||||
kind: AttachedItemKind
|
||||
/** Display name: relative path for files, folder name for dir-handle records. */
|
||||
name: string
|
||||
/** Raw pre-sanitization source name — the re-link identity. Absent on records
|
||||
* persisted before provenance existed (dedupe then falls back to `name`). */
|
||||
sourceName?: string
|
||||
/** Top-level folder (for grouping); equals `name` for dir-handle records. */
|
||||
folder?: string
|
||||
/** Folder-relative path (snapshot folder children) — restores the folder grouping/tree. */
|
||||
|
||||
@@ -9,7 +9,9 @@ vi.mock('../shared', () => ({
|
||||
import { searchFilesTool } from './fileTools'
|
||||
import type { AttachedFile, AttachedFilesStore } from './attachedFiles.svelte'
|
||||
|
||||
/** Minimal store stub: searchFilesTool's empty-ready path only reads count/readyFiles/list. */
|
||||
/** Minimal store stub mirroring the real lookup contract (id first, then
|
||||
* session-name, then message-name — the real ordering is pinned in
|
||||
* attachedFiles.test.ts). */
|
||||
function fakeStore(rows: Array<Partial<AttachedFile>>): AttachedFilesStore {
|
||||
const files = rows as AttachedFile[]
|
||||
return {
|
||||
@@ -17,13 +19,20 @@ function fakeStore(rows: Array<Partial<AttachedFile>>): AttachedFilesStore {
|
||||
return files.length
|
||||
},
|
||||
readyFiles: () => files.filter((f) => f.status === 'ready' && !f.isFolderRoot),
|
||||
list: () => files
|
||||
list: () => files,
|
||||
resolve: (ref: string) =>
|
||||
files.find((f) => f.id === ref) ??
|
||||
files.find((f) => f.name === ref && !f.isFolderRoot && !f.messageScoped) ??
|
||||
files.find((f) => f.name === ref && f.messageScoped)
|
||||
} as unknown as AttachedFilesStore
|
||||
}
|
||||
|
||||
async function runSearch(store: AttachedFilesStore): Promise<string> {
|
||||
async function runSearch(
|
||||
store: AttachedFilesStore,
|
||||
args: Record<string, unknown> = { pattern: 'x' }
|
||||
): Promise<string> {
|
||||
const res = await searchFilesTool.fn({
|
||||
args: { pattern: 'x' },
|
||||
args,
|
||||
helpers: { attachedFiles: store },
|
||||
toolId: 't',
|
||||
toolCallbacks: { setToolStatus: () => {} }
|
||||
@@ -64,3 +73,52 @@ describe('search_files — attachments present but nothing readable', () => {
|
||||
expect(msg).toMatch(/still being indexed/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roster — folder names are raw disk keys, sanitized at render', () => {
|
||||
it('strips control characters from locked/unavailable folder lines', async () => {
|
||||
const { buildAttachedFilesRoster } = await import('./fileTools')
|
||||
const store = {
|
||||
folders: [{ name: 'bad\nfolder', status: 'locked', files: [] }],
|
||||
standalone: [],
|
||||
messageAttached: []
|
||||
} as any
|
||||
const roster = buildAttachedFilesRoster(store)
|
||||
expect(roster).toContain('- bad folder (locked')
|
||||
expect(roster).not.toContain('bad\nfolder')
|
||||
})
|
||||
})
|
||||
|
||||
// Display names may collide (same-named attachments on different messages). In
|
||||
// this environment `new Worker` throws, so searchFilesInWorker takes its
|
||||
// main-thread fallback — the search logic exercised is the same.
|
||||
describe('search_files — colliding display names', () => {
|
||||
const colliding = () =>
|
||||
fakeStore([
|
||||
{
|
||||
name: 'notes.md',
|
||||
status: 'ready',
|
||||
messageScoped: true,
|
||||
id: 'fAAA',
|
||||
file: new Blob(['alpha target\n'])
|
||||
},
|
||||
{
|
||||
name: 'notes.md',
|
||||
status: 'ready',
|
||||
messageScoped: true,
|
||||
id: 'fBBB',
|
||||
file: new Blob(['bravo target\n'])
|
||||
}
|
||||
])
|
||||
|
||||
it('labels unscoped hits with the id that resolves back to the matching row', async () => {
|
||||
const out = await runSearch(colliding(), { pattern: 'target' })
|
||||
expect(out).toContain('notes.md (file id: fAAA):1: alpha target')
|
||||
expect(out).toContain('notes.md (file id: fBBB):1: bravo target')
|
||||
})
|
||||
|
||||
it('an id-scoped search hits only its own row', async () => {
|
||||
const out = await runSearch(colliding(), { pattern: 'target', file: 'fBBB' })
|
||||
expect(out).toContain('bravo target')
|
||||
expect(out).not.toContain('alpha target')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type SearchHit
|
||||
} from './fileEngine'
|
||||
import type { AttachedFile, AttachedFilesStore } from './attachedFiles.svelte'
|
||||
import { sanitizeAttachmentName } from '../textFileUtils'
|
||||
|
||||
/** Slice of the GLOBAL tool helpers that exposes the attached-files store. */
|
||||
export interface AttachedFilesHelper {
|
||||
@@ -34,7 +35,7 @@ function storeFrom(helpers: unknown): AttachedFilesStore | undefined {
|
||||
* same accurate status instead of search_files claiming a non-ready file isn't attached.
|
||||
*/
|
||||
function notReadyMessage(store: AttachedFilesStore, file: string): string | undefined {
|
||||
const entry = store.get(file)
|
||||
const entry = store.resolve(file)
|
||||
if (entry?.status === 'ready') return undefined
|
||||
if (entry?.status === 'indexing')
|
||||
return `File "${file}" is still being indexed. Try again shortly.`
|
||||
@@ -44,11 +45,14 @@ function notReadyMessage(store: AttachedFilesStore, file: string): string | unde
|
||||
return `File "${file}" is no longer available (moved, deleted, or its local copy was evicted). Ask the user to re-link it.`
|
||||
if (entry?.status === 'error')
|
||||
return `File "${file}" failed to load: ${entry.error ?? 'unknown error'}.`
|
||||
// Re-sanitized: folder-root placeholder rows carry the raw folder key.
|
||||
const names = store
|
||||
.list()
|
||||
.map((f) => f.name)
|
||||
.map((f) =>
|
||||
f.id ? `${sanitizeAttachmentName(f.name)} (file id: ${f.id})` : sanitizeAttachmentName(f.name)
|
||||
)
|
||||
.join(', ')
|
||||
return `No attached file named "${file}". Attached files: ${names || '(none)'}.`
|
||||
return `No attached file matching "${file}". Attached files: ${names || '(none)'}.`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +84,7 @@ const searchFilesSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional exact filename (as listed under "Attached files") to restrict the search to. Omit to search across all attached files.'
|
||||
'Optional file to restrict the search to: its file id when one is listed, otherwise its exact filename. Omit to search across all attached files.'
|
||||
),
|
||||
ignore_case: z.boolean().optional().describe('Case-insensitive matching. Defaults to false.')
|
||||
})
|
||||
@@ -105,7 +109,11 @@ export const searchFilesTool: Tool<{}> = {
|
||||
const notReady = notReadyMessage(store, parsed.file)
|
||||
if (notReady) return notReady
|
||||
}
|
||||
const ready = store.readyFiles()
|
||||
// Restrict to the resolved row itself, not a name filter: display names may
|
||||
// collide (same-named attachments on different messages), and a name filter
|
||||
// would silently search all of them under one label.
|
||||
const target = parsed.file ? store.resolve(parsed.file) : undefined
|
||||
const ready = target ? [target] : store.readyFiles()
|
||||
if (ready.length === 0) {
|
||||
return noReadyFilesMessage(store)
|
||||
}
|
||||
@@ -113,15 +121,18 @@ export const searchFilesTool: Tool<{}> = {
|
||||
content: `Searching attached files for /${parsed.pattern}/...`
|
||||
})
|
||||
|
||||
// Hit lines are the model's only handle on which row matched, and display
|
||||
// names may collide — label id-bearing rows with the reference that
|
||||
// resolves back to exactly that row.
|
||||
const rows = ready.map((f) => (f.id ? { ...f, name: `${f.name} (file id: ${f.id})` } : f))
|
||||
// Run in a Worker so a pathological model-supplied regex can't freeze the tab.
|
||||
const result = await searchFilesInWorker(ready, parsed.pattern, {
|
||||
flags: parsed.ignore_case ? 'i' : '',
|
||||
pathFilter: parsed.file
|
||||
const result = await searchFilesInWorker(rows, parsed.pattern, {
|
||||
flags: parsed.ignore_case ? 'i' : ''
|
||||
})
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
const scope = parsed.file ? `"${parsed.file}"` : `${ready.length} file(s)`
|
||||
const scope = target ? `"${target.name}"` : `${ready.length} file(s)`
|
||||
if (result.hits.length === 0) {
|
||||
return `No matches for /${parsed.pattern}/ in ${scope}.`
|
||||
}
|
||||
@@ -135,7 +146,9 @@ export const searchFilesTool: Tool<{}> = {
|
||||
}
|
||||
|
||||
const readFileSchema = z.object({
|
||||
file: z.string().describe('Exact filename to read, as listed under "Attached files".'),
|
||||
file: z
|
||||
.string()
|
||||
.describe('File to read: its file id when one is listed, otherwise its exact filename.'),
|
||||
start_line: z.number().int().optional().describe('1-based first line to read. Defaults to 1.'),
|
||||
end_line: z
|
||||
.number()
|
||||
@@ -160,8 +173,8 @@ export const readFileTool: Tool<{}> = {
|
||||
const parsed = readFileSchema.parse(args)
|
||||
const notReady = notReadyMessage(store, parsed.file)
|
||||
if (notReady) return notReady
|
||||
const entry = store.get(parsed.file)!
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Reading "${parsed.file}"...` })
|
||||
const entry = store.resolve(parsed.file)!
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Reading "${entry.name}"...` })
|
||||
|
||||
try {
|
||||
const res = await readFile(entry, {
|
||||
@@ -181,32 +194,59 @@ export const readFileTool: Tool<{}> = {
|
||||
export const fileTools: Tool<{}>[] = [searchFilesTool, readFileTool]
|
||||
|
||||
function rosterLine(f: AttachedFile): string {
|
||||
if (f.status === 'indexing') return `- ${f.name} (indexing…)`
|
||||
if (f.status === 'locked') return `- ${f.name} (locked — needs the user to restore access)`
|
||||
if (f.status === 'unavailable') return `- ${f.name} (unavailable)`
|
||||
if (f.status === 'error') return `- ${f.name} (failed to load)`
|
||||
return `- ${f.name} — ${f.lineCount} lines, ${humanSize(f.size)}`
|
||||
// Message rows are addressed by their stable id (names may collide); session
|
||||
// rows by name. Names are sanitized at render: this block is model-facing
|
||||
// prompt text and stored names (legacy, folder children) may carry controls.
|
||||
const ref = f.id
|
||||
? `${sanitizeAttachmentName(f.name)} (file id: ${f.id})`
|
||||
: sanitizeAttachmentName(f.name)
|
||||
if (f.status === 'indexing') return `- ${ref} (indexing…)`
|
||||
if (f.status === 'locked') return `- ${ref} (locked — needs the user to restore access)`
|
||||
if (f.status === 'unavailable') return `- ${ref} (unavailable)`
|
||||
if (f.status === 'error') return `- ${ref} (failed to load)`
|
||||
return `- ${ref} — ${f.lineCount} lines, ${humanSize(f.size)}`
|
||||
}
|
||||
|
||||
/** Build the `## Attached files` system-prompt section (metadata only, never content). */
|
||||
export function buildAttachedFilesRoster(store: AttachedFilesStore): string {
|
||||
export function buildAttachedFilesRoster(
|
||||
store: AttachedFilesStore,
|
||||
orphanedMessageFileIds?: Set<string>
|
||||
): string {
|
||||
const lines: string[] = []
|
||||
for (const folder of store.folders) {
|
||||
// Folder names are RAW disk keys (never sanitized in the store — they must
|
||||
// match the handle, children, and persistence record), so this model-facing
|
||||
// render is where control characters get stripped.
|
||||
const folderName = sanitizeAttachmentName(folder.name)
|
||||
// A locked/unavailable folder has no readable children — one line for the whole folder.
|
||||
if (folder.status === 'locked') {
|
||||
lines.push(`- ${folder.name} (locked — needs the user to restore access)`)
|
||||
lines.push(`- ${folderName} (locked — needs the user to restore access)`)
|
||||
} else if (folder.status === 'unavailable') {
|
||||
lines.push(`- ${folder.name} (unavailable)`)
|
||||
lines.push(`- ${folderName} (unavailable)`)
|
||||
} else {
|
||||
lines.push(...folder.files.map(rosterLine))
|
||||
}
|
||||
}
|
||||
// Message-attached files are deliberately NOT listed here: their reference
|
||||
// lives inside the message that carried them (or the compaction summary),
|
||||
// exactly like DOM picks — the roster only advertises session-wide links.
|
||||
lines.push(...store.standalone.map(rosterLine))
|
||||
// Exception: a message whose API counterpart was dropped by drop-oldest
|
||||
// compaction takes its in-message reference with it, so those attachments must
|
||||
// be advertised here or the model can no longer see they exist.
|
||||
if (orphanedMessageFileIds?.size) {
|
||||
lines.push(
|
||||
...store.messageAttached
|
||||
.filter((f) => f.id && orphanedMessageFileIds.has(f.id))
|
||||
.map(rosterLine)
|
||||
)
|
||||
}
|
||||
if (lines.length === 0) return ''
|
||||
return [
|
||||
'## Attached files',
|
||||
'The user has attached the following files to this conversation. Their contents are NOT included here.',
|
||||
'Use the `search_files` tool to find content with a regex, and `read_file` to read a bounded window of lines.',
|
||||
'Reference a file by its file id when one is shown, otherwise by its filename.',
|
||||
'',
|
||||
lines.join('\n')
|
||||
].join('\n')
|
||||
@@ -218,9 +258,10 @@ export function buildAttachedFilesRoster(store: AttachedFilesStore): string {
|
||||
*/
|
||||
export function appendAttachedFilesRoster(
|
||||
base: ChatCompletionSystemMessageParam,
|
||||
store: AttachedFilesStore
|
||||
store: AttachedFilesStore,
|
||||
orphanedMessageFileIds?: Set<string>
|
||||
): ChatCompletionSystemMessageParam {
|
||||
const roster = buildAttachedFilesRoster(store)
|
||||
const roster = buildAttachedFilesRoster(store, orphanedMessageFileIds)
|
||||
if (!roster || typeof base.content !== 'string') return base
|
||||
return { ...base, content: `${base.content}\n\n${roster}` }
|
||||
}
|
||||
|
||||
@@ -4207,6 +4207,37 @@ describe('prepareGlobalUserMessage', () => {
|
||||
expect(message.content).not.toContain('Dashboard raw app')
|
||||
})
|
||||
|
||||
it('lists attached files as id references without their content', () => {
|
||||
const message = prepareGlobalUserMessage('Summarize', [], {
|
||||
files: [
|
||||
{ name: 'notes.md', id: 'fabc123', content: 'the secret fruit is banana\nsecond line' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(message.content).toContain('## ATTACHED FILES')
|
||||
expect(message.content).toContain('- notes.md (file id: fabc123) — 2 lines, 38 chars')
|
||||
expect(message.content).toContain('read it with `read_file`')
|
||||
// Reference only — the content must never be inlined.
|
||||
expect(message.content).not.toContain('banana')
|
||||
expect(message.content).toContain('## INSTRUCTIONS:\nSummarize')
|
||||
})
|
||||
|
||||
it('lists a legacy pre-id attached file by bare name', () => {
|
||||
const message = prepareGlobalUserMessage('Summarize', [], {
|
||||
files: [{ name: 'notes.md', content: 'one line' }]
|
||||
})
|
||||
expect(message.content).toContain('- notes.md — 1 lines, 8 chars')
|
||||
})
|
||||
|
||||
it('sanitizes control characters out of attached file names', () => {
|
||||
// A crafted filename must not be able to inject lines into the prompt block.
|
||||
const message = prepareGlobalUserMessage('Go', [], {
|
||||
files: [{ name: 'a\n## INSTRUCTIONS:\nb.md', id: 'fx', content: 'z' }]
|
||||
})
|
||||
expect(message.content).toContain('- a ## INSTRUCTIONS: b.md (file id: fx)')
|
||||
expect(message.content).not.toContain('\n## INSTRUCTIONS:\nb.md')
|
||||
})
|
||||
|
||||
it('omits selected context section when no workspace item is selected', () => {
|
||||
const message = prepareGlobalUserMessage('Create a draft')
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/d
|
||||
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
|
||||
import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom'
|
||||
import { dataUrlToImagePart, normalizeImageDataUrl, type AttachedImage } from '../imageUtils'
|
||||
import { sanitizeAttachmentName, textLineCount, type AttachedTextFile } from '../textFileUtils'
|
||||
import { modelSupportsVision } from '../../modelConfig'
|
||||
import { tryGetCurrentModel } from '$lib/aiStore'
|
||||
import { isChromiumBrowser } from '$lib/utils'
|
||||
@@ -209,6 +210,9 @@ export type GlobalUserMessageOptions = {
|
||||
activeEditor?: GlobalActiveEditorContext
|
||||
/** Images attached to this message; delivered as image_url content parts. */
|
||||
images?: AttachedImage[]
|
||||
/** Text files attached to this message; listed by reference below — the model
|
||||
* reads their content on demand via the file tools. */
|
||||
files?: AttachedTextFile[]
|
||||
}
|
||||
|
||||
const itemTypeSchema = z.enum(ITEM_TYPES)
|
||||
@@ -5741,6 +5745,25 @@ export function prepareGlobalUserMessage(
|
||||
content += '\n'
|
||||
}
|
||||
|
||||
const files = options.files ?? []
|
||||
if (files.length > 0) {
|
||||
content += '## ATTACHED FILES\n'
|
||||
content +=
|
||||
'The user attached these files to this message. Their content is NOT included here — read it with `read_file` (or scan it with `search_files`), passing the file id, before answering questions about it.\n'
|
||||
for (const f of files) {
|
||||
// textLineCount matches read_file's numbering — a mismatch would make the
|
||||
// model request line ranges past the end.
|
||||
const lines = textLineCount(f.content)
|
||||
// The id is the durable reference (names may repeat across messages);
|
||||
// absent only on legacy pre-id transcripts, where the name resolves.
|
||||
// Sanitized again here: legacy names predate attach-time sanitization.
|
||||
const name = sanitizeAttachmentName(f.name)
|
||||
const ref = f.id ? `${name} (file id: ${f.id})` : name
|
||||
content += `- ${ref} — ${lines} lines, ${f.content.length} chars\n`
|
||||
}
|
||||
content += '\n'
|
||||
}
|
||||
|
||||
content += `## INSTRUCTIONS:\n${instructions}`
|
||||
|
||||
const images = options.images ?? []
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* A message draft: the four lanes that ship together with one send — text,
|
||||
* pastes, images, text files. Every place a draft accumulates or moves
|
||||
* (composer attach, queue append, dequeue restore, failure restore) goes
|
||||
* through this type, so the draft rules — file dedupe by source identity,
|
||||
* courtesy rename, attachment slot caps, all-lanes-move-together — live here
|
||||
* once instead of being re-implemented at each aggregation point.
|
||||
*
|
||||
* Deliberately NOT owned here: the conversation byte budget (needs
|
||||
* manager-wide state — enforced at the composer until it moves into the
|
||||
* store) and @context/DOM picks (ContextManager owns their lifecycle).
|
||||
*/
|
||||
import { MAX_ATTACHED_IMAGES, type AttachedImage } from './imageUtils'
|
||||
import type { PasteAttachment } from './pasteTokens'
|
||||
import {
|
||||
admitWithinByteBudget,
|
||||
foldIntoDraft,
|
||||
MAX_ATTACHED_FILES,
|
||||
type AttachedTextFile
|
||||
} from './textFileUtils'
|
||||
|
||||
/** A draft's four lanes as plain data — what moves between owners. */
|
||||
export interface DraftSnapshot {
|
||||
text: string
|
||||
pastes: PasteAttachment[]
|
||||
images: AttachedImage[]
|
||||
files: AttachedTextFile[]
|
||||
}
|
||||
|
||||
export class MessageDraft {
|
||||
text = $state('')
|
||||
pastes = $state<PasteAttachment[]>([])
|
||||
images = $state<AttachedImage[]>([])
|
||||
files = $state<AttachedTextFile[]>([])
|
||||
|
||||
constructor(seed?: Partial<DraftSnapshot>) {
|
||||
if (seed?.text) this.text = seed.text
|
||||
if (seed?.pastes) this.pastes = [...seed.pastes]
|
||||
if (seed?.images) this.images = [...seed.images]
|
||||
if (seed?.files) this.files = [...seed.files]
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return (
|
||||
this.text.trim() === '' &&
|
||||
this.pastes.length === 0 &&
|
||||
this.images.length === 0 &&
|
||||
this.files.length === 0
|
||||
)
|
||||
}
|
||||
|
||||
/** Files joining a draft always fold (dedupe by source identity, courtesy
|
||||
* rename) and respect the slot cap. `byteBudget`, when given, admits the
|
||||
* folded entries by their decoded size — the fold must run first because
|
||||
* dedupe changes what gets charged. Returns dropped counts so the caller can
|
||||
* toast — the draft has no UI. */
|
||||
addFiles(
|
||||
reads: { name: string; content: string; sourceName?: string }[],
|
||||
byteBudget?: number
|
||||
): { droppedAtCap: number; droppedAtBudget: number } {
|
||||
let folded = foldIntoDraft(this.files, reads)
|
||||
let droppedAtBudget = 0
|
||||
if (byteBudget !== undefined) {
|
||||
const res = admitWithinByteBudget(folded, byteBudget)
|
||||
folded = res.admitted
|
||||
droppedAtBudget = res.dropped
|
||||
}
|
||||
const merged = [...this.files, ...folded]
|
||||
const droppedAtCap = Math.max(0, merged.length - MAX_ATTACHED_FILES)
|
||||
this.files = merged.slice(0, MAX_ATTACHED_FILES)
|
||||
return { droppedAtCap, droppedAtBudget }
|
||||
}
|
||||
|
||||
/** Images join up to the slot cap. Returns the dropped count (caller toasts). */
|
||||
addImages(images: AttachedImage[]): number {
|
||||
const merged = [...this.images, ...images]
|
||||
const dropped = Math.max(0, merged.length - MAX_ATTACHED_IMAGES)
|
||||
this.images = merged.slice(0, MAX_ATTACHED_IMAGES)
|
||||
return dropped
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a restored draft on top of this one (queued-message delete, restore
|
||||
* after a cancelled/errored turn): the restored draft was written FIRST, so
|
||||
* its text lands above and its attachments ahead of the newer ones — at the
|
||||
* caps it is the newest additions that drop, never the restored draft.
|
||||
* Returns whether text merged onto a non-empty draft (the caller must then
|
||||
* keep both drafts' context), plus dropped counts for toasts.
|
||||
*/
|
||||
prepend(restored: { text: string; images?: AttachedImage[]; files?: AttachedTextFile[] }): {
|
||||
mergedIntoDraft: boolean
|
||||
droppedImages: number
|
||||
droppedFiles: number
|
||||
} {
|
||||
const mergedIntoDraft = !!restored.text && !!this.text.trim()
|
||||
// An attachment-only restore has empty text; prepending would only add blank lines.
|
||||
if (restored.text) {
|
||||
this.text = this.text.trim() ? `${restored.text}\n\n${this.text}` : restored.text
|
||||
}
|
||||
let droppedImages = 0
|
||||
if (restored.images?.length) {
|
||||
const merged = [...restored.images, ...this.images]
|
||||
droppedImages = Math.max(0, merged.length - MAX_ATTACHED_IMAGES)
|
||||
this.images = merged.slice(0, MAX_ATTACHED_IMAGES)
|
||||
}
|
||||
let droppedFiles = 0
|
||||
if (restored.files?.length) {
|
||||
// The restored entries were already a normalized draft; the current
|
||||
// (newer) files fold against them so dedupe/rename still apply.
|
||||
const merged = [...restored.files, ...foldIntoDraft(restored.files, this.files)]
|
||||
droppedFiles = Math.max(0, merged.length - MAX_ATTACHED_FILES)
|
||||
this.files = merged.slice(0, MAX_ATTACHED_FILES)
|
||||
}
|
||||
return { mergedIntoDraft, droppedImages, droppedFiles }
|
||||
}
|
||||
|
||||
/** Replace the draft with a snapshot, but only when it is empty — an occupied
|
||||
* draft keeps what the user is writing. Returns whether the restore was taken. */
|
||||
replaceIfEmpty(snapshot: Partial<DraftSnapshot>): boolean {
|
||||
if (!this.isEmpty) return false
|
||||
this.replace(snapshot)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Unconditionally replace all lanes (put a taken queue back, etc.). */
|
||||
replace(snapshot: Partial<DraftSnapshot>): void {
|
||||
this.text = snapshot.text ?? ''
|
||||
this.pastes = [...(snapshot.pastes ?? [])]
|
||||
this.images = [...(snapshot.images ?? [])]
|
||||
this.files = [...(snapshot.files ?? [])]
|
||||
}
|
||||
|
||||
/** Snapshot and clear atomically — the four lanes always move together, so no
|
||||
* call site can take one and forget another. */
|
||||
take(): DraftSnapshot {
|
||||
const snapshot: DraftSnapshot = {
|
||||
text: this.text,
|
||||
pastes: this.pastes,
|
||||
images: this.images,
|
||||
files: this.files
|
||||
}
|
||||
this.clear()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.text = ''
|
||||
this.pastes = []
|
||||
this.images = []
|
||||
this.files = []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { MessageDraft } from './messageDraft.svelte'
|
||||
|
||||
// Fold/rename/dedupe semantics are pinned in textFileUtils.test.ts; these pin
|
||||
// the draft-level guarantees: lanes move together, restores respect occupancy,
|
||||
// aggregation always applies the rules.
|
||||
|
||||
describe('MessageDraft', () => {
|
||||
it('take() snapshots and clears all four lanes atomically', () => {
|
||||
const d = new MessageDraft({
|
||||
text: 'hello',
|
||||
pastes: [{ id: 'p1', content: 'x' } as any],
|
||||
images: [{ dataUrl: 'i1' } as any],
|
||||
files: [{ name: 'a.md', content: 'a' }]
|
||||
})
|
||||
const snap = d.take()
|
||||
expect(snap.text).toBe('hello')
|
||||
expect(snap.pastes).toHaveLength(1)
|
||||
expect(snap.images).toHaveLength(1)
|
||||
expect(snap.files).toHaveLength(1)
|
||||
expect(d.isEmpty).toBe(true)
|
||||
})
|
||||
|
||||
it('replaceIfEmpty declines when any lane is occupied', () => {
|
||||
const d = new MessageDraft({ files: [{ name: 'a.md', content: 'a' }] })
|
||||
expect(d.replaceIfEmpty({ text: 'restored' })).toBe(false)
|
||||
expect(d.files).toHaveLength(1)
|
||||
d.clear()
|
||||
expect(d.replaceIfEmpty({ text: 'restored' })).toBe(true)
|
||||
expect(d.text).toBe('restored')
|
||||
})
|
||||
|
||||
it('prepend puts the restored draft first and folds the newer files against it', () => {
|
||||
const d = new MessageDraft({ text: 'typing', files: [{ name: 'a.md', content: 'a' }] })
|
||||
const res = d.prepend({
|
||||
text: 'restored',
|
||||
files: [
|
||||
{ name: 'a.md', content: 'a' }, // identical to the newer draft's copy → it dedupes
|
||||
{ name: 'a (2).md', content: 'b', sourceName: 'a.md' }
|
||||
]
|
||||
})
|
||||
expect(res.mergedIntoDraft).toBe(true)
|
||||
expect(d.text).toBe('restored\n\ntyping')
|
||||
expect(d.files.map((f) => f.name)).toEqual(['a.md', 'a (2).md'])
|
||||
})
|
||||
|
||||
it('prepend gives the restored draft chronological priority at the caps', () => {
|
||||
// The restored draft was written first — the cap must drop the NEWEST
|
||||
// additions, never the restored attachments.
|
||||
const d = new MessageDraft({
|
||||
files: Array.from({ length: 6 }, (_, i) => ({ name: `new${i}.md`, content: `${i}` }))
|
||||
})
|
||||
const res = d.prepend({
|
||||
text: '',
|
||||
files: Array.from({ length: 3 }, (_, i) => ({ name: `old${i}.md`, content: `o${i}` }))
|
||||
})
|
||||
expect(res.droppedFiles).toBe(1)
|
||||
expect(d.files.map((f) => f.name)).toEqual([
|
||||
'old0.md',
|
||||
'old1.md',
|
||||
'old2.md',
|
||||
'new0.md',
|
||||
'new1.md',
|
||||
'new2.md',
|
||||
'new3.md',
|
||||
'new4.md'
|
||||
])
|
||||
})
|
||||
|
||||
it('addFiles reports drops at the slot cap and the byte budget', () => {
|
||||
const d = new MessageDraft()
|
||||
const many = Array.from({ length: 10 }, (_, i) => ({ name: `${i}.md`, content: `${i}` }))
|
||||
expect(d.addFiles(many).droppedAtCap).toBe(2)
|
||||
expect(d.files).toHaveLength(8)
|
||||
|
||||
const e = new MessageDraft()
|
||||
// Budget admits by decoded size AFTER the fold — the identical duplicate is
|
||||
// deduped, not charged.
|
||||
const res = e.addFiles(
|
||||
[
|
||||
{ name: 'a.md', content: 'aaaa' },
|
||||
{ name: 'a.md', content: 'aaaa' },
|
||||
{ name: 'b.md', content: 'bbbb' },
|
||||
{ name: 'c.md', content: 'cccc' }
|
||||
],
|
||||
8
|
||||
)
|
||||
expect(e.files.map((f) => f.name)).toEqual(['a.md', 'b.md'])
|
||||
expect(res.droppedAtBudget).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ export const SPECIAL_MODULE_IDS = {
|
||||
import { get } from 'svelte/store'
|
||||
import type { PasteAttachment } from './pasteTokens'
|
||||
import { dataUrlToImagePart, type AttachedImage } from './imageUtils'
|
||||
import type { AttachedTextFile } from './textFileUtils'
|
||||
import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } from './context'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
@@ -473,6 +474,10 @@ export type UserDisplayMessage = BaseDisplayMessage & {
|
||||
// Images the user attached to this message (drag/drop/paste), rendered as
|
||||
// thumbnails in the bubble. The LLM message carries them as image_url parts.
|
||||
images?: AttachedImage[]
|
||||
// Text files the user attached to this message, rendered as chips in the
|
||||
// bubble. The prompt lists them by reference; the content here is the durable
|
||||
// copy, re-registered into the session file store on load for tool reads.
|
||||
files?: AttachedTextFile[]
|
||||
}
|
||||
|
||||
export type CreatedResourceTriggerKind =
|
||||
@@ -571,13 +576,20 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
|
||||
/**
|
||||
* Compaction boundary: replaces the summarized prefix in BOTH displayMessages
|
||||
* and the API messages (where it is a plain user message). It carries no index
|
||||
* because it is never a restart target — only the surviving tail's user
|
||||
* messages are rewound to.
|
||||
* and the API messages (where it is a plain user message). It is never a restart
|
||||
* target — only the surviving tail's user messages are rewound to.
|
||||
*/
|
||||
export type SummaryDisplayMessage = {
|
||||
role: 'summary'
|
||||
content: string
|
||||
// Index of the summary's API message, tracked ONLY so orphan detection can tell
|
||||
// when a later drop-oldest compaction drops it (index goes negative) and its
|
||||
// carried files must move to the roster. Not a restart target. Absent on
|
||||
// summaries loaded from pre-existing history.
|
||||
index?: number
|
||||
// Files attached to messages the summary folded away — carried forward so
|
||||
// they stay tool-readable (and reload-safe) after compaction.
|
||||
files?: AttachedTextFile[]
|
||||
}
|
||||
|
||||
export type DisplayMessage =
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Message-scoped text-file attachments for the GLOBAL chat composer.
|
||||
*
|
||||
* Text files attach like images: chips in the composer, riding the next
|
||||
* message, cleared on send. Unlike images, only a *reference* (name + size)
|
||||
* enters the prompt — the content is registered into the session file store at
|
||||
* send and the model reads it on demand via the file tools, same as a DOM pick
|
||||
* is inspected via the DOM tools. The full content stays on the message for
|
||||
* the bubble preview and for re-registration on edit/retry.
|
||||
*/
|
||||
import { isTextFile } from './files/fileEngine'
|
||||
|
||||
export type AttachedTextFile = {
|
||||
name: string
|
||||
/** Stable reference the transcript, prompt, and file tools join on — a content
|
||||
* hash of (name, content), see attachedTextFileId. The name is display-only
|
||||
* and may collide freely. Absent only on transcripts persisted before ids
|
||||
* existed; hydrated (deterministically, from the same hash) on chat load. */
|
||||
id?: string
|
||||
/** Original filename before a courtesy rename (set only when one happened).
|
||||
* Lets duplicate detection recognize a re-drop of the same source file without
|
||||
* inferring provenance from the display name — a user's real `report (2).md`
|
||||
* must never be mistaken for a rename of `report.md`. */
|
||||
sourceName?: string
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Files one message may carry. Enforced wherever a message is assembled, not
|
||||
* just at the composer: queuing clears the composer, so its own count would
|
||||
* reset and let repeated sends stack an unbounded batch into a single message.
|
||||
*/
|
||||
export const MAX_ATTACHED_FILES = 8
|
||||
|
||||
/**
|
||||
* Per-file byte cap. The model reads content on demand (never inlined), so
|
||||
* this only bounds what rides the message state and the chat history's
|
||||
* persisted snapshot — a sanity ceiling, not a context-window one. Larger
|
||||
* files can be linked via their folder instead.
|
||||
*/
|
||||
export const MAX_TEXT_FILE_BYTES = 1_000_000
|
||||
|
||||
/**
|
||||
* Cumulative cap across a conversation. Message-file content lives inside the
|
||||
* transcript (DisplayMessage.files) and is rewritten with every history save,
|
||||
* so without a conversation-level bound repeated attachments would grow the
|
||||
* in-memory record and its IndexedDB copy without limit. Enforced at attach
|
||||
* time against transcript + queue + composer bytes.
|
||||
*/
|
||||
export const MAX_CONVERSATION_FILE_BYTES = 5_000_000
|
||||
|
||||
/** Display names are rendered into model-facing prompt blocks, and OS
|
||||
* filenames may legally contain control characters (even newlines on POSIX) —
|
||||
* a crafted name must not be able to inject prompt structure. Applied at
|
||||
* attach and again wherever a (possibly legacy) name is printed for the model. */
|
||||
export function sanitizeAttachmentName(name: string): string {
|
||||
return name.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim() || 'file'
|
||||
}
|
||||
|
||||
/** Read a file for message attachment. Returns null when the sniff says binary
|
||||
* or the content exceeds MAX_TEXT_FILE_BYTES — the cap is enforced here at the
|
||||
* reader, not only at callers' pre-checks, so no ingestion path can persist an
|
||||
* oversized attachment (decoding can also grow past the raw size when malformed
|
||||
* UTF-8 expands to replacement characters).
|
||||
* The id is minted by the composer after name finalization (a same-name clash in
|
||||
* one draft gets a courtesy rename first, and the id hashes the final name). */
|
||||
export async function fileToAttachedTextFile(file: File): Promise<AttachedTextFile | null> {
|
||||
if (file.size > MAX_TEXT_FILE_BYTES) return null
|
||||
if (!(await isTextFile(file))) return null
|
||||
const content = await file.text()
|
||||
if (textByteLength(content) > MAX_TEXT_FILE_BYTES) return null
|
||||
return { name: sanitizeAttachmentName(file.name), content }
|
||||
}
|
||||
|
||||
/** Line count as the file tools report it (fileEngine.buildLineIndex): an empty
|
||||
* file has 0 lines and a trailing newline is not an extra line. The prompt must
|
||||
* advertise the same number or the model requests invalid read_file ranges. */
|
||||
export function textLineCount(content: string): number {
|
||||
if (content === '') return 0
|
||||
return content.split('\n').length - (content.endsWith('\n') ? 1 : 0)
|
||||
}
|
||||
|
||||
/** Admit files in order while their DECODED byte size fits `budget`. Admission
|
||||
* pre-checks use raw File.size, but the committed charge is the decoded UTF-8
|
||||
* length, which malformed input inflates (an invalid byte decodes to a 3-byte
|
||||
* replacement character) — so the commit step must re-check against what will
|
||||
* actually be charged. */
|
||||
export function admitWithinByteBudget(
|
||||
files: AttachedTextFile[],
|
||||
budget: number
|
||||
): { admitted: AttachedTextFile[]; dropped: number } {
|
||||
const admitted: AttachedTextFile[] = []
|
||||
let dropped = 0
|
||||
for (const f of files) {
|
||||
const bytes = textByteLength(f.content)
|
||||
if (bytes <= budget) {
|
||||
admitted.push(f)
|
||||
budget -= bytes
|
||||
} else {
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
return { admitted, dropped }
|
||||
}
|
||||
|
||||
// cyrb53 (public-domain hash by bryc) — chosen over crypto.subtle because it is
|
||||
// synchronous and works on plain-HTTP deployments where SubtleCrypto is absent.
|
||||
function cyrb53(str: string, seed: number): number {
|
||||
let h1 = 0xdeadbeef ^ seed
|
||||
let h2 = 0x41c6ce57 ^ seed
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str.charCodeAt(i)
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761)
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677)
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
|
||||
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
|
||||
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
|
||||
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic content-hash id for a message attachment. Identity derived from
|
||||
* the file itself: re-registration after reload/rollback lands on the same id by
|
||||
* construction, identical attaches dedupe, and legacy transcripts hydrate their
|
||||
* ids without migration state. Two seeded cyrb53 passes (~106 bits) — collision
|
||||
* odds are negligible at conversation scale, and the context is not adversarial
|
||||
* (a user's own attachments).
|
||||
*/
|
||||
export function attachedTextFileId(name: string, content: string): string {
|
||||
// NUL separator: unambiguous split (filenames cannot contain it), so
|
||||
// two (name, content) pairs never hash alike across the boundary.
|
||||
const input = `${name}\u0000${content}`
|
||||
return `f${cyrb53(input, 1).toString(36)}${cyrb53(input, 2).toString(36)}`
|
||||
}
|
||||
|
||||
/** Return `files` with every entry carrying its id (legacy rows hydrated). */
|
||||
export function withAttachedTextFileIds(files: AttachedTextFile[]): AttachedTextFile[] {
|
||||
return files.map((f) => (f.id ? f : { ...f, id: attachedTextFileId(f.name, f.content) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold freshly-read files into a draft's attachment list: identical
|
||||
* (name, content) duplicates are dropped, same-name-different-content clashes
|
||||
* get the courtesy rename, and ids are minted from the final name. Must run
|
||||
* against the LIVE list in the synchronous commit step — attach batches overlap
|
||||
* (each awaits its file reads), so dedupe/rename decisions made mid-read would
|
||||
* be stale by commit time.
|
||||
*/
|
||||
export function foldIntoDraft(
|
||||
current: AttachedTextFile[],
|
||||
reads: { name: string; content: string; sourceName?: string }[]
|
||||
): AttachedTextFile[] {
|
||||
const commit: AttachedTextFile[] = []
|
||||
for (const f of reads) {
|
||||
const draft = [...current, ...commit]
|
||||
// Sanitized here, not assumed: the fold is the draft side's single choke
|
||||
// point, so every rule a name needs is applied by this one call — a future
|
||||
// entry point cannot skip one.
|
||||
const readName = sanitizeAttachmentName(f.name)
|
||||
// "Same file dropped twice" means same original (name, content) — a
|
||||
// courtesy-renamed copy carries its original name in sourceName rather than
|
||||
// inferring provenance from the display name (a user's real `report (2).md`
|
||||
// is not a rename of `report.md`). Folds compose: a file renamed by an
|
||||
// earlier fold (composer → queue → dequeue) keeps its original source.
|
||||
const src = f.sourceName ? sanitizeAttachmentName(f.sourceName) : readName
|
||||
if (
|
||||
draft.some(
|
||||
(x) => x.content === f.content && (x.name === readName || (x.sourceName ?? x.name) === src)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const name = uniqueDraftFileName(
|
||||
readName,
|
||||
draft.map((x) => x.name)
|
||||
)
|
||||
commit.push({
|
||||
name,
|
||||
content: f.content,
|
||||
id: attachedTextFileId(name, f.content),
|
||||
...(name !== src ? { sourceName: src } : {})
|
||||
})
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
/** Courtesy rename for a same-name clash within one message draft: `notes.md` →
|
||||
* `notes (2).md`. Display-only — identity is the id, and names may collide
|
||||
* across messages — but two identical labels inside one draft would be
|
||||
* indistinguishable to the user and the model alike. */
|
||||
export function uniqueDraftFileName(original: string, taken: Iterable<string>): string {
|
||||
const names = new Set(taken)
|
||||
if (!names.has(original)) return original
|
||||
const dot = original.lastIndexOf('.')
|
||||
const base = dot > 0 ? original.slice(0, dot) : original
|
||||
const ext = dot > 0 ? original.slice(dot) : ''
|
||||
let n = 2
|
||||
while (names.has(`${base} (${n})${ext}`)) n++
|
||||
return `${base} (${n})${ext}`
|
||||
}
|
||||
|
||||
/** UTF-8 byte length of attachment content — budget math must match the byte
|
||||
* caps, and string length undercounts multibyte text. */
|
||||
export function textByteLength(content: string): number {
|
||||
return new TextEncoder().encode(content).length
|
||||
}
|
||||
Reference in New Issue
Block a user