Files
multica/apps/mobile/lib/markdown/preprocess.ts
Naiyuan Qing a19e60a9e6 feat(chat): support images/files in agent chat replies (MUL-4287) (#5164)
* 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
`![name](url)` 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>
2026-07-13 09:15:36 +08:00

128 lines
5.8 KiB
TypeScript

/**
* Pure string transforms applied before marked.lexer parses the content.
*
* Two passes, both idempotent:
* 1. Legacy mention shortcodes `[@ id="..." label="..."]` → modern
* mention link `[@Label](mention://member/id)`. Old DB rows from before
* the April 2026 migration use the shortcode form; the modern form is
* what marked.js can naturally tokenize as a markdown link. Calls into
* `@multica/core/markdown` (single source of truth — same regex web/
* desktop run).
*
* 2. File card lines `!file[name](url)` → standard link `[📎 name](url)`.
* marked.js doesn't recognize the `!file` prefix; web's preprocess
* turns it into HTML, which mobile can't render natively. Rewriting
* to a normal link with a 📎 emoji makes it a tappable link that
* `Linking.openURL` opens in the system viewer (Safari for PDFs,
* QuickLook for docs, share sheet for arbitrary files).
*
* NOTE: Web's preprocess also has a third pass that detects bare CDN
* URLs as legacy file links. We skip that because mobile doesn't bootstrap
* the cdnDomain config. Old comments using the legacy form render as plain
* hyperlinks — same tap behavior, just no 📎 prefix. Acceptable degradation.
*/
import { preprocessMentionShortcodes } from "@multica/core/markdown";
// File-card line matcher, kept in sync with web's parser in
// `packages/ui/markdown/file-cards.ts` (NEW_FILE_CARD_RE + FILE_CARD_URL_PATTERN):
//
// - Label allows backslash-escaped metacharacters (`\[ \] \\ \( \)`) so a
// filename like `a]b.pdf` — which the CLI escapes to `a\]b.pdf` in its
// `!file[...]` output (see cmd_attachment.go escapeMarkdownLabel) — is
// captured whole. Backslash is excluded from the negated class so
// overlapping alternatives can't backtrack (ReDoS, web #4881).
// - URL is restricted to the same allowlist web accepts: site-relative
// `/uploads/...` and `/api/attachments/<UUID>/download`, plus absolute
// `http(s)://`. Anything else (`javascript:`, `data:`, `//host`, other
// `/api/…`) is left as plain text so a stored card can't become an
// out-of-band navigation.
const ATTACHMENT_UUID =
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
const FILE_CARD_URL = `/uploads/[^)]*|https?://[^)]+|/api/attachments/${ATTACHMENT_UUID}/download`;
const FILE_LINE_RE = new RegExp(
`^!file\\[((?:\\\\.|[^\\]\\\\])*)\\]\\((${FILE_CARD_URL})\\)$`,
);
// Unescape the file-card label back to the real filename (mirrors web's
// `newMatch[1].replace(/\\([[\]\\()])/g, "$1")`).
function unescapeFileLabel(label: string): string {
return label.replace(/\\([[\]\\()])/g, "$1");
}
// Re-escape only the characters that would break a markdown LINK label, so the
// emitted `[📎 name](url)` stays valid markdown. Mobile's target is a link
// (re-parsed by marked / the enriched renderer), unlike web's HTML
// `data-filename` attribute — so a raw `]` must not truncate the link text.
function escapeLinkLabel(name: string): string {
return name.replace(/([\\[\]])/g, "\\$1");
}
function preprocessFileCards(input: string): string {
return input
.split("\n")
.map((line) => {
const m = line.trim().match(FILE_LINE_RE);
if (!m) return line;
const label = escapeLinkLabel(unescapeFileLabel(m[1]!));
return `[📎 ${label}](${m[2]})`;
})
.join("\n");
}
/**
* Add GFM strikethrough markers around the content of checked task list items
* so they render with `~~text~~` styling — matching Linear / Notion / Apple
* Reminders / Things 3, where a checked item is visually crossed out.
*
* GFM itself does not specify that checked items SHOULD be struck through;
* enriched-markdown's task-list renderer only changes the checkbox glyph and
* (via `checkedTextColor`) dims the text. Without the strikethrough the
* "done" state reads weakly, and users who expect the platform pattern from
* other task apps assume the checkbox didn't take effect.
*
* Idempotent: skips lines whose body is already wrapped in `~~ ... ~~`.
* Conservative regex — only matches `- [x]` / `* [x]` / `+ [x]` at the start
* of a line (allowing leading whitespace), case-insensitive on the `x`.
*/
const TASK_DONE_RE = /^(\s*[-*+]\s+\[[xX]\]\s+)(.+)$/gm;
function preprocessTaskListStrikethrough(input: string): string {
return input.replace(TASK_DONE_RE, (match, prefix, body) => {
const trimmed = body.trim();
if (trimmed.startsWith("~~") && trimmed.endsWith("~~")) return match;
return `${prefix}~~${body}~~`;
});
}
/**
* Strip embedded HTML before marked sees it. Mobile cannot do what web does
* (rehype-raw + sanitize → render real <br> / <sub> / <details>) — RN has
* no inline HTML. Without this pass, users see literal `<br>` tags in the
* comment body. Strategy:
*
* - `<br>` / `<br/>` / `<br />` → `" \n"` (two trailing spaces + newline,
* the canonical CommonMark hard-break syntax). md4c respects it as a
* hard line break inside a paragraph; bare `\n` would be treated as a
* space (CommonMark default), losing intentional `<br>` semantics.
* - HTML comments `<!-- ... -->` → removed entirely.
* - Every other tag → strip the tag, keep the inner text. So
* `<sub>2</sub>` becomes `2`. Loses formatting but keeps content; far
* better than showing raw HTML.
*
* Does not parse — pure regex. Cannot handle nested tags with attributes
* containing `>`, but those don't appear in our editor output.
*/
function stripHtml(input: string): string {
return input
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<br\s*\/?>/gi, " \n")
.replace(/<\/?[a-z][^>]*>/gi, "");
}
export function preprocessMobileMarkdown(input: string): string {
if (!input) return "";
return preprocessTaskListStrikethrough(
preprocessFileCards(preprocessMentionShortcodes(stripHtml(input))),
);
}