mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +02:00
* feat(chat): support images/files in agent chat replies (MUL-4287) Agents can now attach images/files to their chat replies, matching how comment attachments already work. The write-side gap was that the assistant chat_message is synthesized server-side from the completion callback's text output and never bound any attachments. Backend: - migration 150: nullable attachment.task_id (+ partial index), the transient handle that ties an agent's in-run upload to the reply it produces. - POST /api/upload-file accepts task_id: gated to the task's own agent, in this workspace, on a chat task; tags the row with task_id + chat_session_id. - CompleteTask (chat branch) binds the task's still-unclaimed attachments to the assistant message via BindChatAttachmentsToMessage (rejects rows already owned by an issue/comment/chat_message). An empty-output reply that produced files still creates a message so the images have an owner. FailTask binds nothing. CLI: - `multica attachment upload <path>` uploads a file for the current chat task (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a ready-to-paste markdown snippet. Prompt: - web/mobile chat prompt tells the agent how to attach a file to its reply. Mobile: - chat:done handler now always invalidates the messages list so attachments (absent from the event payload) refetch; mirrors web's self-heal. - chat bubbles render standalone attachment cards via the existing CommentAttachmentList (dedup vs inline references), matching web. Web/desktop needed no change — they already render message.attachments inline and via AttachmentList, and self-heal on chat:done. Tests: upload permission/isolation, bind-on-complete, empty-output+attachments, FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output contract, mobile refetch-on-done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address review blockers on chat reply attachments (MUL-4287) Two final-review blockers on PR #5164: 1. Mobile inline dedup only checked raw `url`, so an attachment referenced inline via `markdown_url` (exactly what the CLI snippet emits) rendered twice — once inline, once as a standalone card. Reuse the core `contentReferencesAttachment` helper so dedup covers every real reference form (stable /api/attachments/<id>/download path, url, download_url, markdown_url), matching web's AttachmentList. Extracted the filter into a pure `lib/attachment-dedup.ts` so it is unit-testable, and added a regression test covering `content` containing `attachment.markdown_url` (plus the other URL forms and same-identity sibling dedup). 2. CLI `attachment upload` emitted `![...]` image markdown for every file, producing a broken-image snippet for non-images. Emit image markdown only for image/* content types and a plain link otherwise, with a CLI contract test for both. Approved scope otherwise unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287) Merged latest main; main renumbered its migrations and now occupies 150-156, so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the next unique prefix (157). No content change; migrate up applies cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): render agent-produced files as attachment cards, not raw links The chat upload command handed the agent a bare `[name](url)` markdown snippet. Pasted mid-sentence it renders as a plain text link (not a card), and the referenced URL hides the auto-bound standalone attachment — so a file the agent produced could end up showing as nothing. Return the block-level `!file[name](url)` card syntax instead (images keep `` inline), and markdown-escape the filename so names with `[`/`]` don't truncate the label. The prompt and CLI help now state the file auto-attaches below the reply and the snippet is optional, only for placement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): soften message-list scroll fade (32px → 16px) The 32px edge fade washed out full-bleed content (HTML / image previews) at the list edges. Halve the fade distance so it barely grazes previews while still hinting at more content above/below. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): renumber attachment_task_id migration 157 -> 158 main landed 157_agent_task_delivered_comments while this branch was open, colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Bump this PR's migration to the next free prefix (158). Rename only; the migration body (nullable attachment.task_id + partial index) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): pin attachment upload to the token's task; build index concurrently Two code-review findings on the chat-attachment path (MUL-4287): - Isolation/privacy: POST /api/upload-file only checked the form task_id belonged to the caller's agent, not that it matched the task-scoped token's authoritative X-Task-ID. A run authorized for task A could tag an attachment onto task B (another chat task of the same agent, possibly another user's session), binding it into that reply on completion. Require the form task_id to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression. - Migration: split the task_id lookup index into its own migration (159) built with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a multi-command file with the ADD COLUMN in 158. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287) Addresses the two remaining Preflight BLOCKERs on PR #5164. Security (file.go): the task_id upload path compared the form task_id to X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT leaves that header empty and the middleware does NOT strip a client-forged X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair. So a member who learned a task ID could forge both and inject an attachment onto another chat task's assistant reply (cross-session/privacy leak). Now the branch requires X-Actor-Source=task_token first (mirrors chat_history.go's load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests now go through the real task-token headers and add a forged-JWT-403 regression. Migration (158): task_id is a transient binding handle (written once at upload against an already-validated task, read only during that task's own completion; durable owner is chat_message_id). There is no app-layer path that hard-deletes agent_task_queue rows, and orphan uploads are already reaped by attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a cascade dependency the app never relies on plus write overhead on the hot attachment table. Drop the FK; task_id is now a plain UUID column. Added a regression test that an unbound task-tagged upload is reaped on chat_session delete. Index (159, CONCURRENTLY) unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287) Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up with the CLI's file-card output, so agent-produced non-image files rendered nowhere on mobile. - `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never matched — the line stayed literal AND `standaloneAttachments` still hid the fallback card (the URL is in `content`), so the file showed nowhere. - Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is restricted to the same allowlist (site-relative /uploads + /api/attachments/ <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text. - Unescape the label to the real filename, then re-escape only the chars that would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed by the renderer — unlike web's HTML data-filename), so a raw `]` never truncates the link text. No dedup change: once the inline `!file` renders, hiding the standalone card is correct. Added focused unit tests covering the escaped-label case, parens/ backslash unescape, the site-relative URL form, and disallowed-scheme rejection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
223 lines
7.6 KiB
TypeScript
223 lines
7.6 KiB
TypeScript
/**
|
|
* Mobile-owned WS cache patchers for the chat domain.
|
|
*
|
|
* Pure functions over QueryClient — no React, no WS plumbing. The
|
|
* `use-chat-sessions-realtime` and `use-chat-session-realtime` hooks
|
|
* translate WS events into calls into this module.
|
|
*
|
|
* Why mobile-owned (and not importing from web's chat ws-updaters):
|
|
* - Web binds its updaters to `chatKeys` from packages/core/chat/queries.ts,
|
|
* a different runtime instance than mobile's data/queries/chat.ts. Keys
|
|
* are compared structurally so it'd *appear* to work, but binding cache
|
|
* mutation to a foreign key factory invites silent drift the moment
|
|
* either side adjusts its key shape.
|
|
* - Mobile has a smaller cache surface (no taskMessages live timeline in
|
|
* v1, no per-user pending-tasks aggregate).
|
|
*
|
|
* Cache shapes (the design contract):
|
|
* - chatKeys.sessions(wsId) → ChatSession[]
|
|
* - chatKeys.messages(sessionId) → ChatMessage[] (flat, ASC oldest→newest)
|
|
* - chatKeys.pendingTask(sessionId)→ ChatPendingTask (empty `{}` = no in-flight)
|
|
*/
|
|
import type { QueryClient } from "@tanstack/react-query";
|
|
import type {
|
|
ChatDonePayload,
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
ChatSession,
|
|
ChatSessionDeletedPayload,
|
|
TaskMessagePayload,
|
|
TaskQueuedPayload,
|
|
TaskDispatchPayload,
|
|
} from "@multica/core/types";
|
|
import { chatKeys } from "@/data/queries/chat";
|
|
|
|
// =====================================================
|
|
// Sessions list (ChatSession[] keyed by wsId)
|
|
// =====================================================
|
|
|
|
export function patchSessionListAfterRename(
|
|
qc: QueryClient,
|
|
wsId: string | null,
|
|
payload: {
|
|
chat_session_id: string;
|
|
title?: string;
|
|
updated_at?: string;
|
|
},
|
|
) {
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
|
old?.map((s) =>
|
|
s.id === payload.chat_session_id
|
|
? {
|
|
...s,
|
|
title: payload.title ?? s.title,
|
|
updated_at: payload.updated_at ?? s.updated_at,
|
|
}
|
|
: s,
|
|
),
|
|
);
|
|
}
|
|
|
|
export function dropSessionFromList(
|
|
qc: QueryClient,
|
|
wsId: string | null,
|
|
payload: ChatSessionDeletedPayload,
|
|
) {
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
|
old?.filter((s) => s.id !== payload.chat_session_id),
|
|
);
|
|
qc.removeQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
|
qc.removeQueries({
|
|
queryKey: chatKeys.pendingTask(payload.chat_session_id),
|
|
});
|
|
}
|
|
|
|
export function flipSessionUnread(
|
|
qc: QueryClient,
|
|
wsId: string | null,
|
|
sessionId: string,
|
|
hasUnread: boolean,
|
|
) {
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
|
old?.map((s) =>
|
|
s.id === sessionId ? { ...s, has_unread: hasUnread } : s,
|
|
),
|
|
);
|
|
}
|
|
|
|
// =====================================================
|
|
// Messages cache (ChatMessage[] keyed by sessionId)
|
|
// =====================================================
|
|
|
|
/**
|
|
* Apply `chat:done` to the messages cache.
|
|
*
|
|
* When the payload carries the freshly-persisted assistant message inline
|
|
* (message_id + content + created_at), patch the cache directly so the
|
|
* assistant bubble lands in the same render tick that clears pendingTask
|
|
* — no live-timeline → final-bubble flicker.
|
|
*
|
|
* Older servers (pre-#2123 in web's commit history) sent only chat_session_id
|
|
* + task_id. Detect that and fall back to invalidate; we'll refetch the
|
|
* messages list and accept a one-frame window with no bubble.
|
|
*
|
|
* The inline patch never carries attachments: `ChatDonePayload` has no
|
|
* attachments field by contract, yet an assistant reply may have images/files
|
|
* bound to it server-side. So after the inline patch we ALWAYS invalidate the
|
|
* messages list to refetch the authoritative rows (which include
|
|
* `attachments`). Mirrors web's `use-realtime-sync.ts`, which invalidates
|
|
* unconditionally after its inline patch. The patch is kept purely for the
|
|
* instant, flicker-free bubble; the refetch is what makes attachments appear.
|
|
*/
|
|
export function applyChatDoneToCache(
|
|
qc: QueryClient,
|
|
payload: ChatDonePayload,
|
|
) {
|
|
if (payload.message_id && payload.content != null && payload.created_at) {
|
|
const assistantMsg: ChatMessage = {
|
|
id: payload.message_id,
|
|
chat_session_id: payload.chat_session_id,
|
|
role: "assistant",
|
|
content: payload.content,
|
|
task_id: payload.task_id,
|
|
created_at: payload.created_at,
|
|
elapsed_ms: payload.elapsed_ms ?? null,
|
|
// Mirror web's applyChatDoneToCache: carry the kind so a no_response turn
|
|
// renders its notice inline; missing → "message" for older servers
|
|
// (MUL-4351).
|
|
message_kind: payload.message_kind ?? "message",
|
|
};
|
|
qc.setQueryData<ChatMessage[]>(
|
|
chatKeys.messages(payload.chat_session_id),
|
|
(old) => {
|
|
if (!old) return [assistantMsg];
|
|
// Echo guard — server may re-emit on reconnect.
|
|
if (old.some((m) => m.id === assistantMsg.id)) return old;
|
|
return [...old, assistantMsg];
|
|
},
|
|
);
|
|
}
|
|
// Refetch the authoritative message list so any attachments bound to the
|
|
// assistant reply (absent from the event payload) are pulled in.
|
|
qc.invalidateQueries({
|
|
queryKey: chatKeys.messages(payload.chat_session_id),
|
|
});
|
|
// Clear in-flight pointer in the same tick so StatusPill unmounts and
|
|
// the AssistantMessage owns the rendering.
|
|
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
|
}
|
|
|
|
// =====================================================
|
|
// Pending task (ChatPendingTask keyed by sessionId)
|
|
// =====================================================
|
|
|
|
export function seedPendingTaskFromQueued(
|
|
qc: QueryClient,
|
|
payload: TaskQueuedPayload,
|
|
) {
|
|
if (!payload.chat_session_id) return;
|
|
qc.setQueryData<ChatPendingTask>(
|
|
chatKeys.pendingTask(payload.chat_session_id),
|
|
(old) => ({
|
|
...(old ?? {}),
|
|
task_id: payload.task_id,
|
|
status: "queued",
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function promotePendingTaskToRunning(
|
|
qc: QueryClient,
|
|
payload: TaskDispatchPayload,
|
|
) {
|
|
if (!payload.chat_session_id) return;
|
|
qc.setQueryData<ChatPendingTask>(
|
|
chatKeys.pendingTask(payload.chat_session_id),
|
|
(old) => {
|
|
// Only upgrade if it's the task we already know about. A stale
|
|
// dispatch event for a finished task shouldn't reanimate the pill.
|
|
if (!old || old.task_id !== payload.task_id) return old;
|
|
return { ...old, status: "running" };
|
|
},
|
|
);
|
|
}
|
|
|
|
export function clearPendingTask(
|
|
qc: QueryClient,
|
|
sessionId: string,
|
|
) {
|
|
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
|
|
}
|
|
|
|
// =====================================================
|
|
// Task messages (live timeline, keyed by taskId)
|
|
// =====================================================
|
|
|
|
/**
|
|
* Append a `task:message` payload into the per-task timeline cache.
|
|
*
|
|
* - De-dupes on `seq` (server may re-emit on flaky network).
|
|
* - Sorts by `seq` ASC after insert so reordered late-arriving rows still
|
|
* render in execution order.
|
|
* - Creates the cache entry on first event (empty default), so the timeline
|
|
* is visible even before the user opens the assistant bubble that drives
|
|
* the lazy fetch.
|
|
*
|
|
* Mirrors `packages/core/realtime/use-realtime-sync.ts` ~675-689 (web's
|
|
* single global handler). Mobile attaches per-session via
|
|
* `use-chat-session-realtime` instead — see the WS strategy note in
|
|
* `apps/mobile/CLAUDE.md` for why mobile prefers per-record mounts.
|
|
*/
|
|
export function appendTaskMessage(
|
|
qc: QueryClient,
|
|
payload: TaskMessagePayload,
|
|
) {
|
|
qc.setQueryData<TaskMessagePayload[]>(
|
|
chatKeys.taskMessages(payload.task_id),
|
|
(old = []) => {
|
|
if (old.some((m) => m.seq === payload.seq)) return old;
|
|
return [...old, payload].sort((a, b) => a.seq - b.seq);
|
|
},
|
|
);
|
|
}
|