Skip to content

feat(web): rebuild chat replies on shadcn with sources and citations - #231

Closed
a2751012962 wants to merge 18 commits into
feat/frontendfrom
feat/chat-answer-sources
Closed

a2751012962 wants to merge 18 commits into
feat/frontendfrom
feat/chat-answer-sources

Conversation

@a2751012962

@a2751012962 a2751012962 commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

中文摘要

整理聊天回答的展示,同时把聊天界面从 assistant-ui 换到 shadcn/ui + AI Elements。基于 #220。

  • 回答结构:思考时顶部显示当前步骤和计时;回答开始输出时,这一行换成「N 个来源」按钮(站点首字母圆点 + 数量),点开是带编号的来源列表。没有搜索的回答保留「已思考 N 步 · Xs」。
  • 数字角标:正文里指向来源的链接变成编号角标,悬停显示标题、站点和摘要;手机上点按直接打开网页。不是来源的链接照旧显示。
  • 追问:改成一行横排小按钮(AI Elements Suggestion),放不下时横向滑动。
  • 滚动:保留现在的行为——发送后问题置顶,回答在下面生成,不自动跟随。
  • 来源随消息保存:登录用户存 Supabase messages.sources,访客存 localStorage。需要执行迁移 apps/web/scripts/migrations/add_message_sources.sql;没执行之前消息照常保存,只是不带来源。
  • 体积:每个页面都会加载的 vendor-react 少了 74 kB(gzip),聊天页 chunk 多了 11 kB,JS 合计少 63 kB。
  • 顺带修复:feat(web): redesign frontend with shadcn components #220 的构建错误(删了 vite-plugin-mcp 但还在调用 ViteMcp());回答里的列表恢复显示圆点和序号。

Why

A chat reply was one Markdown block. Sources only appeared as raw URLs the model wrote inline, follow-up questions were stacked vertically, and an inline "Web search" tool card repeated what the thinking steps already said. #220 moves the frontend to shadcn, so the new reply layout is built on shadcn/ui and AI Elements instead of on the assistant-ui runtime that is going away.

What changed

Worker. web_search returns its results as metadata.sources. buildObservation keeps only the sources whose URL is still in the tool result after the registry's 4096-byte cap, since the model never read the ones that were cut off, and it strips sources from the tool message so the snippets are not sent to the model a second time. The loop emits one source-url SSE event per source right after tool_result. The payload (sourceId, url, title, snippet) mirrors the AI SDK source-url part, so it carries over if the chat later moves to the AI SDK stream protocol.

Client. deepseekSse parses source-url into StreamChunk.source, accepting only http(s) URLs. A rejected payload never falls through to the legacy delta parser, so it can't leak into the answer text. useChatSession collects sources per reply, dedupes them by URL across searches, and saves them with the message.

Persistence. conversationService writes messages.sources (jsonb). If PostgREST answers PGRST204 because the column does not exist yet, the message is saved again without its sources. localConversationService stores them for guests.

UI (src/components/chat/):

  • ChatThread owns the session and renders the list, empty state, and sticky composer. useTopAnchoredScroll replaces assistant-ui's turnAnchor="top": a spacer after the list (shrink-0, or flexbox squeezes it to nothing) makes the anchored position reachable, and loading a stored conversation jumps to the end instead.
  • AssistantMessage shows ThinkingProcess until text arrives, then MessageSources (AI Elements Sources). MarkdownContent renders react-markdown directly and turns links to a source into SourceCitation (AI Elements InlineCitation). Matching tolerates the model's rewrites (http vs https, www., trailing slash, #fragment). A bare URL keeps only the number; a named link keeps its text before the number.
  • ChatComposer is a plain form: Enter sends, Shift+Enter adds a line, and Enter that confirms an IME candidate is ignored (nativeEvent.isComposing).
  • Removed: ChatRuntimeProvider, the assistant-ui message adapter and its tests, and WebSearchToolUI. @assistant-ui/react and @assistant-ui/react-markdown are uninstalled.

shadcn setup. components.json sets the cn alias to @/utils/cn, following the repo's src/utils convention, and registers @ai-elements. src/index.css maps the shadcn tokens onto the slate and Illini palette. To leave pages outside the chat untouched: the default border colour only applies to [data-slot] elements (in @layer base, so utilities still win), radius tokens keep Tailwind's defaults, and dark: needs a .dark ancestor because the app is light-only. One local change to a copied component, marked in place: InlineCitationCardTrigger takes a custom label, because the upstream trigger always prints the hostname.

Behavior changes

  • The thinking line is replaced by the sources pill once the answer starts. Replies without sources keep the thinking line.
  • The inline "Web search" tool card is gone; its query and count are in the thinking steps and the sources pill.
  • The regenerate button only renders when a handler is passed, and none is yet. It was already unusable: the external-store runtime had no onReload, and assistant-ui disables Reload without it (reload: this._store.onReload !== void 0 in external-store-thread-runtime-core.js).
  • assistant-ui's even text pacing is gone. Text now lands on the existing 100 ms throttle, and the existing per-segment fade-in still applies.
  • Lists in replies show bullets and numbers (Tailwind's preflight had been removing them, before this PR too).

Before merging

Run apps/web/scripts/migrations/add_message_sources.sql on Supabase. Deploying without it is safe; replies are just saved without their sources until it runs.

Verification

Baseline is #220's head plus the build fix, measured at 1d52b96 before #220 was force-pushed. The rewrite changed only docs and package.json scripts, so the numbers carry over. "This branch" is the PR head, re-checked after rebasing onto the new feat/frontend.

Check Baseline This branch
tsc -p apps/web --noEmit ✓ ✓
node --test "worker/**/*.test.ts" "src/**/*.test.ts" 173 pass 178 pass: 17 new, minus the 12 adapter tests removed with the adapter
vp lint apps/web/src apps/web/worker exit 0 exit 0, no new warnings in changed code
vp build (apps/web) ✓ ✓
vendor-react JS (all pages) 914.4 kB / 284.5 kB gz 654.0 kB / 210.1 kB gz
ChatPage JS 24.6 kB / 8.6 kB gz 61.5 kB / 19.7 kB gz
index.css 106.8 kB / 17.0 kB gz 120.0 kB / 19.1 kB gz

The worker loop tests import .txt prompts, and on Node 24 node --test needs a loader for them. That was already true before this PR.

In the browser (vp dev, /chat, with /api/chat replaced by a scripted SSE stream in the real Worker format):

  • The thinking line advances through the steps with its timer. Sources that arrive before any text do not show yet. The pill replaces the line at the first token.
  • Links became citations: named link → text + 1, bare URL → 1 only, trailing-slash variant → 2, #fragment variant → 3. A non-source link stayed an orange link. Citation numbers compute to slate-500, not the .prose a orange.
  • The hover card shows the title, site, and excerpt. The pill expands to the numbered list.
  • The 💡 follow-up section became three chips. Clicking one sent it, and the real local Worker answered (without Tavily).
  • Second question in a long thread: it landed 24 px from the top (scrollTop equals the computed target). It stayed there while the reply grew from 1698 to 2298 px, the spacer shrank 628 → 0, and the scroll-to-bottom button appeared.
  • Reopening a stored conversation scrolled to the end, showed the saved sources pill, and rebuilt the citations.
  • Guest localStorage held sources per reply.

Not verified here: a real Tavily search (no key locally), and the Supabase write path, including the PGRST204 fallback (no Supabase locally).

CI: Workers Builds

Workers Builds: uiuc fails on this PR, and it fails the same way on feat/frontend itself. Going back along the base history, the last passing commit is 71751f5 and the first failing one is 7654d56 chore(auth): init package (#217), whose only change is the new apps/auth package and its lockfile update. Every commit after it fails, with or without this PR. Locally, pnpm install --frozen-lockfile and vp pack in apps/auth both pass at that commit, so the real error is only in the Cloudflare build log, which the CLI credentials here can't read (403). This PR does fix the separate ViteMcp is not defined build error that #220 introduced.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Failed ❌

View logs ↗
f5c9e01 2026-10-02T07:35:05.195Z View logs ↗
  • Build: Failed ❌

View logs ↗
4ac0a19 2026-10-02T07:29:42.005Z View logs ↗
  • Build: Failed ❌

View logs ↗
65d6dc9 2026-10-02T07:15:02.407Z View logs ↗
  • Build: Failed ❌

View logs ↗
75a5a1e 2026-10-02T07:03:30.041Z View logs ↗
  • Build: Failed ❌

View logs ↗
05556cd 2026-10-02T07:02:12.816Z View logs ↗

@a2751012962
a2751012962 force-pushed the feat/chat-answer-sources branch from 05556cd to 75a5a1e Compare October 2, 2026 07:02
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit f5c9e01.

The previous commit dropped vite-plugin-mcp and its import but kept the call, so every build failed with ViteMcp is not defined.
The chat UI can only cite what it knows the agent read. Sources whose URL the 4096-byte result limit cut off are dropped, since the model never saw them, and sources are stripped from the tool message so snippets are not sent to the model twice.
Signed-in chats store them in a new messages.sources column (scripts/migrations/add_message_sources.sql). Until that migration runs, Supabase rejects the column with PGRST204 and the message is saved again without its sources rather than lost.
Adds the primitives and the Sources, InlineCitation, and Suggestion elements the chat will use. Theme tokens map onto the existing slate and Illini palette. The default border colour applies only to data-slot elements and radius tokens keep Tailwind's defaults, so pages outside the chat render as before; dark: utilities need a .dark ancestor because the app is light-only.
The upstream trigger always shows the source's hostname; a long domain like housing.illinois.edu clutters Chinese prose, so the chat passes the source number instead.
Replaces the assistant-ui runtime and primitives. While a reply thinks, its header shows the current step and a timer; once the answer starts, a sources pill (AI Elements Sources) takes that place, links to those pages become numbered InlineCitation markers, and follow-up questions render as Suggestion chips.

A sent question still scrolls to the top with the reply growing below it unfollowed; useTopAnchoredScroll takes over what turnAnchor="top" did. The regenerate button only renders once a handler is passed: the external-store runtime never had onReload, so it was always disabled. The inline web-search tool card is gone, since its query and count now live in the thinking steps and the sources pill.
Tailwind's preflight removes list-style and nothing restored it inside replies, so bulleted and numbered lists rendered as bare indented lines.
@a2751012962
a2751012962 force-pushed the feat/chat-answer-sources branch from 75a5a1e to 65d6dc9 Compare October 2, 2026 07:14
The shadcn carousel subscribes to reInit and select but only unsubscribed select, so every unmount leaked a reInit listener on the Embla instance.
React Doctor flagged its cyclomatic complexity (18). Each part now owns one branch; behaviour is unchanged.
One write keeps the events in order without awaiting each one in a loop. The tests move to the list API and now also cover ordering and an empty list.
The variant exports in badge and button and the carousel's setApi effect are shadcn and Embla API that other copied components depend on. The exemptions are per file and per rule, so new code in those files is still checked.
@Arcadi4

Arcadi4 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

not yet at this stage

@Arcadi4 Arcadi4 closed this Oct 3, 2026
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