Skip to content

feat: Higgsfield connector + inline video/image previews in chat - #285

Open
xDenside wants to merge 2 commits into
AtomicBot-ai:mainfrom
xDenside:feat/higgsfield-connector-inline-media
Open

feat: Higgsfield connector + inline video/image previews in chat#285
xDenside wants to merge 2 commits into
AtomicBot-ai:mainfrom
xDenside:feat/higgsfield-connector-inline-media

Conversation

@xDenside

@xDenside xDenside commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Describe Your Changes

Higgsfield connector (feat(connectors))

  • Higgsfield's hosted MCP (image/video/audio generation, billed to the user's own credits) is a featured, pinned card in Connectors. Sign in reuses the existing MCP OAuth flow (discovery → DCR → PKCE); the MCP host publishes its own authorization-server metadata in front of Clerk, so no new flow was needed.
  • Catalog entries may name OAuth scopes (oauthScopes), passed through mcpOauthLogin(name, url, scopes?) to mcp_oauth_login. Higgsfield asks for openid email offline_access so a refresh token is issued; absent/empty keeps current behaviour for every other connector.
  • Pinned connectors sort ahead of installed servers in the grid.
  • Verified live: consent page names Atomic Chat, rotating refresh token (24 h access), refreshed token accepted by the MCP, tools/list returns 101 tools. Note for follow-up: that tool block is ~149K tokens; a default tool allowlist for this connector is the obvious next step and is not part of this PR.

Inline media in chat (feat(chat))

  • Tool cards show a gallery above the raw result for any image or video the result points at — URLs with a decodable extension, plus typed generation records ({ type, results: { rawUrl, thumbnailUrl } }, result_url) even without one. Covers chat mode's MCP content array and agent mode's JSON-string outcome.
  • Assistant prose linking a video gets a player under the text; video/* file parts become a video trace block next to image and audio.
  • Agent-written local media (![preview](clip.gif), [Preview x.mp4](file:///…), bare file://) used to hit rehype-harden's blocked placeholder. They are rewritten into https://atomic.local/media?path=… links, resolved against the message's known files then the thread's workspace root, and rendered as <video>/<img> through the asset protocol (other files: open-with-OS link). The agent persona now states the chat renders media, so the model stops disclaiming previews.
  • CSP media-src gains https: (matching img-src); no remote video could play without it. Failed loads degrade to a link; no autoplay.

Two ADRs under docs/decisions/ document both. Verified with Vitest (connectors, services, tool output, trace parts, message/markdown suites), tsc -b and eslint; the Rust changes compiled in a local universal macOS build.

Fixes Issues

  • n/a

Self Checklist

  • Added relevant comments, esp in complex areas
  • Updated docs (for bug fixes / features)
  • Created issues for follow-up changes or refactoring needed — follow-up noted above (default tool allowlist for Higgsfield); no issue filed yet

🤖 Generated with Claude Code

https://claude.ai/code/session_015cqVAA5BaFeZVTuwpqhwyU

xDenside and others added 2 commits September 9, 2026 18:00
Higgsfield's hosted MCP (image, video and audio generation on the user's
own credits) joins the Connectors catalog as a featured, pinned card. Sign
in reuses the existing MCP OAuth flow: the MCP host publishes its own
authorization-server metadata in front of Clerk, so discovery, dynamic
client registration and PKCE work unchanged.

Catalog entries may now name their OAuth scopes (`oauthScopes`), passed
through `mcpOauthLogin(name, url, scopes?)` to `mcp_oauth_login`; Higgsfield
asks for `openid email offline_access` so a refresh token is issued. Empty
or absent keeps today's behaviour for every other connector. Pinned
connectors sort ahead of installed servers in the grid.

Verified live against a signed-in account: consent page names Atomic Chat,
tokens carry a rotating refresh token (24 h access), refreshed tokens are
accepted by the MCP, `tools/list` returns 101 tools.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cqVAA5BaFeZVTuwpqhwyU
…iles inline

Tool cards render a media gallery above the raw result for any image or
video the result points at: URLs with a decodable extension, plus typed
generation records (`{ type, results: { rawUrl, thumbnailUrl } }`,
`result_url`) even without an extension. Works for chat mode's MCP content
array and agent mode's JSON-string outcome. Assistant prose that links a
video gets a player under the text; `video/*` file parts become a video
trace block next to the image and audio ones.

Agent-written local media used to hit rehype-harden's blocked placeholder
(no relative or `file:` sources). Markdown images, links and bare `file://`
URLs that resolve to a local file are rewritten into
`https://atomic.local/media?path=…` links, resolved against the message's
known files and then the thread's workspace root; the link renderer turns
them back into a `<video>` or `<img>` served via the asset protocol, or an
open-with-OS link for other files. The agent persona now says the chat
renders media, so the model stops disclaiming inline previews.

`media-src` gains `https:` in the CSP, matching `img-src`; without it no
remote video could play. A failed load degrades to the URL as a link. No
autoplay, metadata preload only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cqVAA5BaFeZVTuwpqhwyU
@xDenside
xDenside requested a review from Vect0rM as a code owner September 9, 2026 15:01

Vect0rM commented Sep 10, 2026

Copy link
Copy Markdown
Member

Thanks for this, @xDenside — it's a big diff that stays legible, and the two ADRs do a lot of the reviewing for me. A few things I want to note before the findings, because they're the parts that would have been easy to get wrong:

Reusing the existing MCP OAuth flow rather than inventing one for Higgsfield is the right call, and making oauthScopes an optional catalog field — empty meaning the provider's defaults — keeps every other connector byte-identical. The comment explaining why offline_access has to be named rather than assumed is the sort of thing that saves the next person a day. On the media side, routing local files through https://atomic.local/media?path= so rehype-harden leaves them alone, and letting the a renderer turn them back, is a neat way to avoid loosening the hardening itself. And widening media-src to match img-src rather than to something bespoke is the conservative version of that change.

Verified on your branch merged onto current main (64758ea):

  • tsc -b — exit 0.
  • yarn lint — 0 errors, 24 warnings, and main reports the identical 24. Nothing added.
  • Full yarn test — 300 files, 3064 passed, 16 skipped. Baseline on main is 299 / 3044 / 16, so your 20 tests land clean with nothing else disturbed.

I can't compile the Rust side here (no GTK dev libraries on this runner), so oauth/flow.rs and oauth/mod.rs are read rather than built — I'm taking the macOS build on your word. I also can't reach higgsfield.ai or mcp.higgsfield.ai from this environment (both blocked at the egress proxy), so the live verification in your description — consent page, refresh rotation, 101 tools — is likewise yours, not re-checked.

Five things. The first two are the ones I'd like before merge.

1. It doesn't merge — docs/decisions/INDEX.md

One conflict, and it's purely arithmetic: main gained the ATO-461 record on the same day you added your two. Resolution is to keep all three and add up:

237 records, 2026-05-19 → 2026-09-09.

## UI / UX (35)

- **2026-09-09** — [Resolve the reply model on send instead of asking (ATO-461)](…)
- **2026-09-09** — [Play remote video and images from tool results inline in the chat](…)
- **2026-09-09** — [Add Higgsfield as an OAuth connector; a catalog entry may name its OAuth scopes](…)

I resolved it that way locally to run the suites, and everything above was measured on that merge.

2. extractTextVideos runs on every render, unmemoized

MessageItem.tsx:355 calls it inline in JSX:

{extractTextVideos(block.text).map((video) => (

Twenty lines earlier in the same PR, ToolOutput does the same job correctly — const media = useMemo(() => extractToolMedia(output), [output]). The text-block path just missed it.

That matters because block.text is the streaming buffer: it changes on every token, so the scan re-runs over a string that keeps growing. I measured the real function under Vitest, scanning at ~1 tick per token:

message per scan cumulative over the stream
516 chars, no URLs 0.0012 ms 0.1 ms
1,480 chars, 20 URLs 0.92 ms 255 ms
8,920 chars, 80 URLs 3.50 ms 2,722 ms

The /https?:\/\// early-out means a link-free answer is genuinely free — nice. But an answer that cites sources pays 0.9–3.5 ms of main-thread work per frame, which is most of a frame budget on its own, and 0.25–2.7 s cumulative across one response. Wrapping it in useMemo on block.text doesn't remove the recompute during streaming, but it does stop it firing on every unrelated re-render of the list; keying it off the finished text (or skipping the scan while isStreaming) removes it entirely. Given 2026-09-04-bound-streaming-reasoning-render-cost exists, this is a cost the project has already decided it doesn't want to pay twice.

3. The prose-video player isn't gated on isAgentMessage — should it be?

The markdown rewriting is (isAgentMessage ? linkAgentFileReferences(linkAgentLocalMedia(...)) : block.text), but extractTextVideos(block.text) sits outside that ternary, in the role !== 'user' branch. So every assistant message in ordinary chat now gets a player for any .mp4/.webm/.mov URL it happens to mention, and <video preload="metadata"> fetches from that host immediately — no click.

That's a new automatic outbound request triggered by model output, in an app whose pitch is "private, 100% offline". A model summarising a fetched page, or repeating a URL from a search result, can now make the app beacon an arbitrary host without the user doing anything. The CSP change is what permits it, and I think widening media-src to match img-src is defensible on its own — it's the unprompted fetch I'd want a decision on rather than a side effect. Options, in the order I'd pick them: gate the prose player to agent messages (matching the rewriting right above it); or keep it everywhere but render a poster/placeholder that loads on click; or preload="none". Your call — you've thought about the media surface more than I have — but it should be in the ADR either way.

4. Local-file links widen from "files this session touched" to "any path the model writes"

Worth a look because the shape is a familiar one. I ran the two pipelines side by side on the same prose:

main today:
  [Q3 report](/private/tmp/payload.sh)              → left inert by the hardening

with this PR:
  [Q3 report](https://atomic.local/open-file?path=%2Fprivate%2Ftmp%2Fpayload.sh)

resolveAgentLocalPath returns any isAbsoluteAgentPath candidate, whether or not it's among the message's known references, so clicking that link calls opener().openPath() on a path the model chose, under a label the model chose. Before this PR, only paths the agent had actually touched (tool inputs, attachments) became clickable.

I'm not claiming this is a privilege escalation for the agent itself — it already has os.fs and shell. The concern is the label/href mismatch reaching the user's click, on output that can be steered by whatever a fetch or search tool pulled in. Two cheap mitigations, either is fine: restrict the open-file rewrite to paths in context.references (leaving the media rewrite as-is, since rendering a picture is inert), or show the resolved path in the link title so the user can see where it goes. Media links are fine unchanged.

5. Prettier: 8 files

prettier --check flags 15 of your files; 7 of those fail identically on main (INDEX.md, message-trace-parts.ts, $threadId.tsx, services/__tests__/mcp.test.ts, services/mcp/{default,tauri,types}.ts) — pre-existing drift, please leave them. These 8 are new:

docs/decisions/2026-09-09-add-higgsfield-as-an-oauth-connector-with-catalog-declared-scopes.md
docs/decisions/2026-09-09-play-remote-video-and-images-from-tool-results-inline-in-chat.md
web-app/src/components/ai-elements/tools/tool-media.tsx
web-app/src/components/ai-elements/tools/tool-output.test.tsx
web-app/src/components/ai-elements/tools/tool.tsx
web-app/src/containers/MessageItem.tsx
web-app/src/lib/tool-media.test.ts
web-app/src/routes/connectors/__tests__/index.test.tsx

MessageItem.tsx is the fragment you added around the markdown block — the children weren't re-indented under it. agent-file-links.ts and its test are clean, for what it's worth.

Nits

  • pinned and the one-connector rule. The doc comment says "keep it to one", but nothing enforces it and the sort silently accepts several. A .find() for the first pinned entry, or a comment on MCP_CONNECTORS itself, would make the invariant survive the next catalog edit. Separately: pinning a paid third-party service above the user's own installed servers is a product call more than a code one — flagging it so it's a decision, since the ADR doesn't cover the ordering.
  • The ~149K-token tool block. Your checklist notes the follow-up but says no issue was filed. Given DEFAULT_MCP_TOOL_LIST_TIMEOUT_SECS exists precisely because tool listing has bitten this app before, I'd like the issue open before this merges rather than after.
  • No CI has run on this branch — check runs come back total_count: 0.

Rebase for 1, memoize for 2, answer 3, and I'm happy to take it. The Higgsfield wiring itself I have no notes on 🎬


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants