From 30409b67bc1ce32f865389c340d999c91057c411 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Thu, 12 Mar 2026 16:36:42 +0800 Subject: [PATCH 1/8] feat(chat): rich citation cards with metadata, loading stages, and stream throttling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of knowledge base retrieval enhancement: - Backend: batch Paper query for citation metadata (authors/year/doi/chunk_type) - Frontend: CitationCard with collapsible excerpts, relevance badges, DOI links - CitationCardList with stagger animations and show-more pagination - MessageLoadingStages: searching → citations → generating → complete - Stream throttling: text_delta batched at 80ms intervals to reduce Markdown re-parses - Type safety: isCitation guard + normalizeCitation for snippet→excerpt compat - Version bump to v0.2.0 Made-with: Cursor --- backend/app/api/v1/chat.py | 12 + backend/pyproject.toml | 2 +- ...-03-12-rich-citation-rewrite-brainstorm.md | 177 ++++ ...12-feat-rich-citation-rewrite-a2ui-plan.md | 826 ++++++++++++++++++ ...2026-03-12-a2ui-grpc-rich-chat-research.md | 506 +++++++++++ .../2026-03-12-ai-sdk-playwright-research.md | 537 ++++++++++++ .../2026-03-12-framework-docs-research.md | 471 ++++++++++ ...-rag-rich-citation-performance-analysis.md | 283 ++++++ frontend/package.json | 2 +- .../components/playground/CitationCard.tsx | 184 ++++ .../playground/CitationCardList.tsx | 81 ++ .../components/playground/MessageBubble.tsx | 97 +- .../playground/MessageLoadingStages.tsx | 76 ++ frontend/src/pages/PlaygroundPage.tsx | 71 +- frontend/src/types/chat.ts | 30 +- 15 files changed, 3293 insertions(+), 62 deletions(-) create mode 100644 docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md create mode 100644 docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md create mode 100644 docs/research/2026-03-12-a2ui-grpc-rich-chat-research.md create mode 100644 docs/research/2026-03-12-ai-sdk-playwright-research.md create mode 100644 docs/research/2026-03-12-framework-docs-research.md create mode 100644 docs/solutions/performance-issues/2026-03-12-rag-rich-citation-performance-analysis.md create mode 100644 frontend/src/components/playground/CitationCard.tsx create mode 100644 frontend/src/components/playground/CitationCardList.tsx create mode 100644 frontend/src/components/playground/MessageLoadingStages.tsx diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py index f986052..f4e0867 100644 --- a/backend/app/api/v1/chat.py +++ b/backend/app/api/v1/chat.py @@ -15,6 +15,7 @@ from app.api.deps import get_db from app.models.conversation import Conversation from app.models.message import Message +from app.models.paper import Paper from app.schemas.conversation import ChatStreamRequest from app.services.llm.client import LLMClient, get_llm_client from app.services.rag_service import RAGService @@ -94,7 +95,14 @@ async def _stream_chat( f"p.{src.get('page_number', '?')}]\n{src.get('excerpt', '')}" ) + paper_ids = list({pid for pid in (src.get("paper_id") for src in all_sources) if pid is not None}) + papers_by_id: dict[int, Paper] = {} + if paper_ids: + result = await db.execute(select(Paper).where(Paper.id.in_(paper_ids))) + papers_by_id = {p.id: p for p in result.scalars().all()} + for i, src in enumerate(all_sources, 1): + paper = papers_by_id.get(src.get("paper_id")) if src.get("paper_id") else None citation = { "index": i, "paper_id": src.get("paper_id"), @@ -102,6 +110,10 @@ async def _stream_chat( "page_number": src.get("page_number"), "excerpt": src.get("excerpt", ""), "relevance_score": src.get("relevance_score", 0), + "chunk_type": src.get("chunk_type", "text"), + "authors": paper.authors if paper else None, + "year": paper.year if paper else None, + "doi": paper.doi if paper else None, } citations.append(citation) yield _sse("citation", citation) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 830cb6c..dd02aef 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omelette-backend" -version = "0.1.0" +version = "0.2.0" description = "Scientific Literature Lifecycle Management System / 科研文献全生命周期管理系统" readme = "README.md" license = {text = "MIT"} diff --git a/docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md b/docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md new file mode 100644 index 0000000..d078e2d --- /dev/null +++ b/docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md @@ -0,0 +1,177 @@ +--- +date: 2026-03-12 +topic: rich-citation-rewrite +--- + +# 知识库检索增强:富媒体引用展示、内容重写与体验优化 + +## 我们要构建什么 + +在现有 Chat Playground 中全面升级知识库检索体验,涵盖四个维度: + +1. **丰富引用卡片** — 将简陋的引用列表升级为可展开的富媒体引用卡片,展示原文片段、来源信息、相关度分数 +2. **片段重写与对比** — 引用卡片上可对原文片段进行多风格重写(简化/学术/翻译/自定义),重写结果以对比形式展示 +3. **内联标注与联动** — AI 回答中的引用标记 `[1][2]` 变为可交互元素,hover 预览原文,颜色区分来源 +4. **富媒体渲染与体验优化** — 引入 A2UI 协议实现 LLM 驱动的富媒体 UI、美化聊天界面加载动画、并行 LLM 调用提速 + +## 为什么选择渐进增强方案 + +考虑过三种方案: + +- **渐进增强现有消息(选定)**:改动最小,复用现有架构,用户无需学习新界面,每步迭代都能独立上线 +- **独立工作面板**:空间充裕但开发量大,偏离聊天范式 +- **新工具模式**:架构一致但需定义新 SSE 事件,第一步过重 + +渐进增强方案符合 YAGNI 原则,从改善引用卡片这个"最小有用增量"开始,后续逐步引入富媒体和性能优化。 + +## 关键决策 + +- **入口位置**:在现有 Playground 聊天界面中增强,不新建页面 +- **第一步优先级**:丰富引用卡片展示(展示 excerpt、来源信息、相关度) +- **重写风格**:灵活选择,支持简化、学术改写、翻译、自定义 prompt +- **可视化方式**:综合使用卡片展开、左右对比、内联高亮,按步骤逐步引入 +- **数据基础**:后端已返回 `excerpt` 和 `relevance_score`,前端目前未使用,第一步零后端改动 +- **富媒体协议**:采用 Google A2UI 协议实现 LLM 驱动的声明式 UI 渲染 +- **性能优化**:并行 LLM 调用,优化流式传输协议 + +## 分步实施细节 + +### 第一步:丰富引用卡片 + A2UI 基础设施 + 聊天 UI 美化 + +**现状问题:** +- 后端 citation 返回 `excerpt`(原文片段),前端类型定义为 `snippet` 但未展示 +- 引用列表只显示 `[index] paper_title (p.page_number)`,信息量不足 +- 引用不可交互,无法查看原文内容 +- 聊天加载动画简陋(仅打字机光标),缺少视觉反馈 + +**目标效果:** +- 引用卡片默认折叠,展示:序号、论文标题、页码、相关度 badge +- 点击展开后展示:原文片段(excerpt)高亮显示、论文元数据(作者/年份/DOI) +- 相关度用颜色梯度表示(高→绿,中→黄,低→灰) +- 卡片底部预留"重写"按钮位置(第二步激活) +- 新加载动画:思考中骨架屏 + 引用卡片渐入动画 + 流式文字渲染优化 +- A2UI 基础设施就绪,引用卡片优先使用 A2UI 渲染 + +**技术要点:** +- 安装 `@a2ui/react` + `@a2ui/web-lib`,搭建 A2UI 渲染管线 +- 定义 `CitationCard` 为 A2UI 组件目录中的首个自定义组件 +- 统一前后端字段:`snippet` → `excerpt` +- 前端 `Citation` 类型增加 `excerpt`、`relevance_score`、`authors`、`year`、`doi` 字段 +- `MessageBubble` 增加 A2UI 分支:收到 A2UI 消息用渲染器,否则降级 Markdown +- 加载动画使用 CSS `@keyframes` + `transition`,实现骨架屏 → 内容渐入过渡 + +### 第二步:片段重写与对比 + +**目标效果:** +- 引用卡片上增加"重写"按钮,点击弹出重写风格选择器 +- 重写风格:简化、学术改写、中英互译、自定义 prompt +- 重写结果以 diff 对比视图展示:左侧原文、右侧重写,差异高亮 +- 重写结果可复制、可收藏 + +**技术要点:** +- 新增后端 API:`POST /api/v1/chat/rewrite`,接收 excerpt + style,返回重写结果 +- 前端新建 `RewritePanel` 组件,使用 diff 库做文本对比 +- 重写调用使用 SSE 流式输出 + +### 第三步:内联标注与联动 + +**目标效果:** +- AI 回答正文中的 `[1]`、`[2]` 解析为可交互标签 +- Hover 标签弹出 popover 预览原文片段 +- 不同来源用颜色区分(最多 5-6 种颜色循环) +- 点击标签滚动到对应引用卡片 + +**技术要点:** +- 自定义 ReactMarkdown 插件,解析 `[数字]` 模式 +- 使用 Radix Tooltip/Popover 做预览 +- 颜色映射跟随 citation index + +### 第四步:A2UI 富媒体渲染 + 并行 LLM 优化 + +#### 4A. A2UI 协议集成 + +**背景:** Google A2UI(v0.8 稳定版)是一个声明式 UI 协议——AI Agent 生成 JSON 描述组件结构,客户端使用原生组件安全渲染,无需执行任意代码。 + +**为什么适合 Omelette:** +- 当前聊天只能输出 Markdown 文本,无法渲染图表、交互式表格、知识图谱等 +- A2UI 让 LLM 能"画 UI":比如检索结果可以渲染为交互式论文关系图、对比表格、时间线视图 +- 声明式 JSON 格式对 LLM 友好,支持流式渐进渲染,与现有 SSE 架构兼容 +- React 渲染器已稳定(`@a2ui/react`),可直接集成 + +**目标效果:** +- LLM 可根据查询类型动态选择输出格式:纯文本、引用卡片、对比表格、关系图 +- 用户看到的不仅是文字回答,还有交互式的可视化组件 +- 组件可点击、展开、排序,与底层数据绑定 + +**技术要点:** +- 安装 `@a2ui/react` + `@a2ui/web-lib` +- 定义 Omelette 组件目录(Component Catalog):CitationCard、ComparisonTable、PaperTimeline、KnowledgeGraph 等 +- 扩展 SSE 事件:新增 `a2ui_surface` 事件类型,传输 A2UI JSON 消息 +- 后端 LLM prompt 中注入 A2UI 组件 schema,让模型能生成结构化 UI 描述 +- `MessageBubble` 增加分支:收到 A2UI 消息时用 A2UI 渲染器,否则降级为 Markdown + +#### 4B. 并行 LLM 调用优化 + +**背景:** 当前某些场景(如多知识库检索、重写+摘要同时进行)需要多次串行 LLM 调用,用户等待时间长。 + +**优化方向:** + +| 策略 | 适用场景 | 实现方式 | +|------|---------|---------| +| **asyncio 并发** | 多知识库同时检索 | `asyncio.gather()` 并发调用 RAG | +| **多流合并** | 检索+生成同步进行 | 后端并行发起 RAG 检索和 LLM 生成,通过 SSE 多事件类型分流 | +| **gRPC 双向流** | 高吞吐、低延迟场景 | 替代 HTTP SSE,支持双向流和多路复用 | +| **HTTP/2 Server Push** | 中间方案 | 复用现有 HTTP 基础设施,多路复用降延迟 | + +**选定路径:直接引入 gRPC 双向流** + +一步到位实现多路复用和双向通信,不走中间过渡方案。 + +**技术要点:** +- 后端引入 `grpcio` + `grpcio-tools`,定义 `ChatService` proto(含双向流 RPC) +- 前端通过 `grpc-web`(或 `@connectrpc/connect-web`)连接 +- 支持多路复用:检索流、生成流、重写流可同时进行,共享一个连接 +- 后端并行化:`asyncio.gather()` 并发调用 RAG 检索和 LLM 生成 +- 保留 SSE 作为降级方案,gRPC 不可用时自动回退 +- proto 定义:`ChatStreamRequest`、`ChatStreamResponse`(含 oneof 消息类型) + +## UI 与 LLM 生成内容的联动机制 + +A2UI 的核心联动方式: + +``` +用户输入 → 后端 LLM 分析意图 → 选择输出格式 → 生成 A2UI JSON → SSE 流式传输 → 前端 A2UI 渲染器 → 交互式 UI + ↕ + 用户交互事件 → 回传后端 → LLM 继续处理 +``` + +**意图到 UI 的映射举例:** +- "比较这三篇论文的方法" → `ComparisonTable` 组件(交互式对比表格) +- "展示这个领域的研究时间线" → `PaperTimeline` 组件(可缩放时间线) +- "这段话的来源是什么" → `CitationCard` 组件(展开式引用卡片) +- 普通问答 → 降级为 Markdown 文本 + 引用列表 + +## 聊天加载动画美化 + +**现状:** 仅有打字机光标闪烁(`●`),缺少分阶段视觉反馈。 + +**目标:** 加载过程分阶段展示,让用户感知系统正在做什么: + +| 阶段 | 动画效果 | +|------|---------| +| 检索中 | 脉冲式搜索图标 + "正在检索 X 篇文献..." | +| 分析中 | 骨架屏渐入,模拟内容结构 | +| 生成中 | 文字逐步渲染 + 引用卡片淡入 | +| 完成 | 内容固定 + 交互元素激活 | + +使用 CSS `@keyframes` + `transition` 实现,保持轻量,不引入重型动画库。 + +## 已解决的问题 + +1. **A2UI 引入时机** → 第一步就预埋基础设施,安装 `@a2ui/react`,用 A2UI 渲染引用卡片,为后续打基础 +2. **并行 LLM 策略** → 直接引入 gRPC 双向流,一步到位实现多路复用,不走 asyncio 中间方案 +3. **组件目录范围** → 丰富集:CitationCard、ComparisonTable、PaperTimeline、RewriteDiff、KnowledgeGraph、StatsDashboard、ExportPanel + +## 下一步 + +→ `/ce:plan` 开始第一步(丰富引用卡片 + A2UI 基础设施 + 聊天 UI 美化)的实施规划 diff --git a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md new file mode 100644 index 0000000..96ef2a1 --- /dev/null +++ b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md @@ -0,0 +1,826 @@ +--- +title: "feat: 知识库检索增强 — 富媒体引用、内容重写与 A2UI 集成" +type: feat +status: active +date: 2026-03-12 +version: v0.2.0 +origin: docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md +--- + +# feat: 知识库检索增强 — 富媒体引用、内容重写与 A2UI 集成 + +## Enhancement Summary + +**深化日期**:2026-03-12 +**审查代理**:Python Reviewer、TypeScript Reviewer、Performance Oracle、Architecture Strategist +**研究代理**:Best Practices Researcher、Framework Docs Researcher、SpecFlow Analyzer + +### 关键改进(来自深化审查) + +1. **Citation 批量查询修正**:示例代码存在 N+1 问题,已修正为 `select(Paper).where(Paper.id.in_(paper_ids))` +2. **asyncio.to_thread 精确使用**:Rewrite API 使用 LangChain `astream`(已异步),**不需要** `asyncio.to_thread()`,仅 LlamaIndex 同步调用需要 +3. **流式渲染性能 P0**:`text_delta` 需节流到 50-100ms 或每 5-10 token 批量更新,避免每 token 触发完整 Markdown 重解析 +4. **XSS 防护**:excerpt 渲染 **禁止** `dangerouslySetInnerHTML`,改用 `react-markdown` 安全渲染 +5. **类型安全**:SSE 事件解析需类型守卫(`isCitation`),禁止 `as unknown as Citation` +6. **Phase 4 范围收缩**:A2UI 组件初期从 7 个收缩为 3 个核心组件(CitationCard、RewriteDiff、StatsDashboard) +7. **connect-python 实际为 Alpha**(非 Beta),Phase 4 启动前需 POC 验证 +8. **历史数据迁移**:`snippet` → `excerpt` 需兼容已持久化的 `Message.citations` JSON + +### 新发现的风险 + +- 浏览器端 grpc-web **不支持双向流**,已调整为 server streaming +- `@a2ui/react` NPM 包可能未正式发布,需在 Phase 4 前确认 +- remark 插件链顺序可能影响现有 `remarkGfm`/`remarkMath`,Phase 3 需回归测试 + +--- + +## 1. Overview + +### 1.1 愿景 + +在现有 Chat Playground 中全面升级知识库检索体验:将简陋的引用列表升级为可展开的富媒体引用卡片,支持原文片段重写与对比,AI 回答中的引用标记变为可交互元素,最终引入 Google A2UI 协议实现 LLM 驱动的动态富媒体 UI 渲染。 + +### 1.2 当前状态 vs 目标 + +| 维度 | 现状 | 目标 | +|------|------|------| +| **引用展示** | 简单列表 `[index] title (p.X)`,未展示 excerpt | 可展开卡片:原文片段 + 元数据 + 相关度 badge | +| **原文内容** | 后端返回 `excerpt` 但前端未使用 | 展示原文片段,支持重写与对比 | +| **引用交互** | 无交互 | [1][2] 可 hover 预览、点击跳转、颜色区分 | +| **重写能力** | 无 | 多风格重写(简化/学术/翻译/自定义)+ Diff 对比 | +| **加载动画** | 仅 `●` 打字机光标 | 分阶段动画:检索→骨架屏→渐入→激活 | +| **富媒体** | 仅 Markdown + KaTeX | A2UI 声明式组件:表格、时间线、关系图 | +| **传输协议** | HTTP SSE 单向流 | ConnectRPC 多路复用 + SSE 降级 | + +### 1.3 核心价值 + +- **信息密度提升**:引用卡片展示原文片段,用户无需离开聊天界面即可审阅来源 +- **写作辅助**:对原文片段进行多风格重写,加速学术写作 +- **溯源可视化**:内联引用标注让信息来源一目了然 +- **富媒体表达**:A2UI 让 LLM 不再局限于纯文本,可输出交互式图表和表格 +- **性能提升**:并行 LLM 调用减少用户等待时间 + +### 1.4 前置计划 + +本计划基于已完成的 `docs/plans/2026-03-11-feat-chat-streaming-citations-plan.md`(聊天系统、流式输出与引用追踪),在其基础上进行增强。 + +--- + +## 2. Technical Approach + +### 2.1 架构概览 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Frontend (React 19 + Tailwind v4) │ +│ │ +│ PlaygroundPage │ +│ ├── MessageBubble (enhanced) │ +│ │ ├── MarkdownRenderer ─── InlineCitationTag [1][2] │ +│ │ │ └── CitationPopover (hover preview) │ +│ │ ├── CitationCardList │ +│ │ │ └── CitationCard (collapsible) │ +│ │ │ ├── excerpt + metadata + relevance badge │ +│ │ │ └── RewriteButton → RewritePanel (diff view) │ +│ │ ├── A2UISurfaceRenderer (Phase 4) │ +│ │ │ └── renders custom components │ +│ │ └── LoadingStages (skeleton → content transition) │ +│ └── ChatInput (unchanged) │ +│ │ +│ A2UI Component Catalog │ +│ ├── CitationCard, ComparisonTable, PaperTimeline │ +│ ├── RewriteDiff, KnowledgeGraph, StatsDashboard │ +│ └── ExportPanel │ +│ │ +│ Transport Layer │ +│ ├── SSE (current, default) │ +│ └── ConnectRPC (Phase 4, with SSE fallback) │ +└──────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ Backend (FastAPI) │ +│ │ +│ POST /api/v1/chat/stream ─── _stream_chat() │ +│ ├── SSE events: message_start, citation*, text_delta*, message_end │ +│ ├── citation 增强: + authors, year, doi (from Paper table) │ +│ └── Phase 4: + a2ui_surface event │ +│ │ +│ POST /api/v1/chat/rewrite (Phase 2, NEW) │ +│ ├── Request: { excerpt, style, custom_prompt? } │ +│ └── Response: SSE stream of rewritten text │ +│ │ +│ ConnectRPC Service (Phase 4, NEW) │ +│ ├── ChatService.StreamChat (server streaming RPC) │ +│ └── ChatService.Rewrite (unary RPC) │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Implementation Phases + +### Phase 1: 丰富引用卡片 + 聊天加载动画美化 + +**预估工作量**:3-5 天 + +#### 3.1.1 后端:Citation 数据增强 + +**文件**:`backend/app/api/v1/chat.py` + +当前 citation 事件仅包含 `index`、`paper_id`、`paper_title`、`page_number`、`excerpt`、`relevance_score`。需从 Paper 表补充元数据。 + +```python +# 批量查询 Paper 避免 N+1(审查修正) +paper_ids = list({ + pid for pid in (src.get("paper_id") for src in all_sources) + if pid is not None +}) + +papers_by_id: dict[int, Paper] = {} +if paper_ids: + result = await db.execute(select(Paper).where(Paper.id.in_(paper_ids))) + papers_by_id = {p.id: p for p in result.scalars().all()} + +for i, src in enumerate(all_sources, 1): + paper = papers_by_id.get(src.get("paper_id")) + citation = { + "index": i, + "paper_id": src.get("paper_id"), + "paper_title": src.get("paper_title", ""), + "page_number": src.get("page_number"), + "excerpt": src.get("excerpt", ""), + "relevance_score": src.get("relevance_score", 0), + "chunk_type": src.get("chunk_type", "text"), + "authors": paper.authors if paper else None, + "year": paper.year if paper else None, + "doi": paper.doi if paper else None, + } + citations.append(citation) + yield _sse("citation", citation) +``` + +> **审查洞察**:原示例代码存在 N+1 查询问题(每个 source 单独 `db.get`)。上述修正使用 `IN` 查询批量获取所有 Paper,再用 dict 做 O(1) 查找。注意 `paper_ids` 为空时不要执行 `IN ()`,SQLite 可能报错。 + +**文件变更清单**: +- `backend/app/api/v1/chat.py` — 增强 citation 构建,批量查询 Paper + +#### 3.1.2 前端:类型统一与 Citation 扩展 + +**文件**:`frontend/src/types/chat.ts` + +```typescript +export interface Citation { + index: number; + paper_id: number; + paper_title: string; + chunk_type: string; + page_number: number; + relevance_score: number; + excerpt: string; // 统一字段名,原 snippet + authors?: string | null; // 新增 + year?: number | null; // 新增 + doi?: string | null; // 新增 +} +``` + +**迁移**:全局替换 `snippet` → `excerpt`(前端 `Citation` 类型和所有引用处)。 + +> **审查洞察**:历史 `Message.citations` JSON 列中已持久化的数据仍使用 `snippet` 字段。需要在前端 `isCitation` 类型守卫中兼容两者:优先读取 `excerpt`,不存在时回退到 `snippet`。 + +**SSE 类型安全(审查补充)**: + +```typescript +function isCitation(data: unknown): data is Citation { + return ( + typeof data === 'object' && data !== null && + 'index' in data && 'paper_id' in data && + ('excerpt' in data || 'snippet' in data) + ); +} +``` + +禁止使用 `event.data as unknown as Citation` 不安全断言。 + +**文件变更清单**: +- `frontend/src/types/chat.ts` — `Citation` 类型扩展 +- `frontend/src/pages/PlaygroundPage.tsx` — citation 事件处理适配 +- `frontend/src/components/playground/MessageBubble.tsx` — 引用渲染重构 + +#### 3.1.3 前端:CitationCard 组件 + +**新文件**:`frontend/src/components/playground/CitationCard.tsx` + +**设计**: +- 默认折叠状态:序号 badge + 论文标题(截断)+ 页码 + 相关度 badge +- 展开状态:原文片段(`excerpt`)+ 论文元数据(作者/年份/DOI) +- 相关度颜色:`>0.8` 绿色、`>0.5` 黄色、`<=0.5` 灰色 +- 使用 Radix `Collapsible` 实现展开/折叠动画 +- 卡片底部预留 rewrite 按钮位(第二阶段激活,初始 `disabled`) + +**边缘情况处理**: +- `citations.length === 0`:隐藏引用区域 +- `excerpt` 超长(>500 字):折叠展示前 300 字 + "展开全文"按钮 +- `excerpt` 含特殊字符(HTML/公式):使用 `react-markdown` 安全渲染(**禁止 `dangerouslySetInnerHTML`**,存在 XSS 风险) +- 15+ 引用:使用虚拟列表(`@tanstack/react-virtual`)或"显示更多"按钮 + +```typescript +type CitationColorIndex = 0 | 1 | 2 | 3 | 4 | 5; + +interface CitationCardProps { + citation: Citation; + colorIndex: CitationColorIndex; // 约束为 6 色调色板 + isExpanded: boolean; + onToggle: () => void; + onRewrite?: (excerpt: string) => void; // Phase 2 +} +``` + +> **审查洞察**:`colorIndex` 应约束为 `0-5`,避免越界。`CitationCardList` 和 `CitationCard` 都应使用 `React.memo()`,`citations` 数组引用需通过 `useMemo` 稳定化,避免父组件 `text_delta` 更新导致整个引用列表重渲染。 + +**文件变更清单**: +- `frontend/src/components/playground/CitationCard.tsx` — 新建 +- `frontend/src/components/playground/CitationCardList.tsx` — 新建,管理展开状态 + +#### 3.1.4 前端:聊天加载动画美化 + +**文件**:`frontend/src/components/playground/MessageBubble.tsx` + +替换现有的 `animate-pulse` 圆点为分阶段加载动画: + +| SSE 阶段 | 触发条件 | 动画效果 | +|----------|---------|---------| +| 等待响应 | `message_start` 之前 | 脉冲搜索图标 + "正在检索文献..." | +| 接收引用 | 首个 `citation` 事件 | 引用卡片骨架屏淡入 | +| 生成文本 | 首个 `text_delta` 事件 | 骨架屏渐出 + 文字渐入 | +| 完成 | `message_end` 事件 | 内容固定 + 引用卡片交互激活 | + +使用项目已有的 `framer-motion` + `frontend/src/lib/motion.ts` 中的共享 variants。 + +**新文件**:`frontend/src/components/playground/MessageLoadingStages.tsx` + +```typescript +type LoadingStage = 'searching' | 'citations' | 'generating' | 'complete'; +``` + +**制度知识(来自 docs/solutions/)**: +- `MessageBubble` 必须使用 `React.memo()` 减少流式重渲染(see: codebase-quality-audit) +- 骨架屏使用 `CardSkeleton` 模式替代 spinner(see: comprehensive-ui-polish) +- 动画统一从 `@/lib/motion` 引入(see: comprehensive-ui-polish) + +#### 3.1.5 性能优化:流式渲染节流(P0) + +> **性能审查发现**:每个 `text_delta` 事件触发 `setMessages` → `MessageBubble` 重渲染 → 完整 Markdown 重解析(remark + rehype),长回答时 CPU 压力极大。 + +**解决方案**:在 `PlaygroundPage` 中节流 `text_delta` 的 state 更新: + +```typescript +// 每 50-100ms 或每 5-10 个 token 批量更新一次 +const pendingDelta = useRef(''); +const flushTimer = useRef>(); + +function handleTextDelta(delta: string) { + pendingDelta.current += delta; + if (!flushTimer.current) { + flushTimer.current = setTimeout(() => { + setMessages(prev => /* 合并 pendingDelta.current */); + pendingDelta.current = ''; + flushTimer.current = undefined; + }, 80); // 80ms 节流 + } +} +``` + +**文件变更清单**: +- `frontend/src/components/playground/MessageLoadingStages.tsx` — 新建 +- `frontend/src/components/playground/MessageBubble.tsx` — 集成加载阶段 +- `frontend/src/pages/PlaygroundPage.tsx` — 流式 text_delta 节流 + +#### 3.1.6 验收标准(Phase 1) + +- [x] 引用卡片默认折叠,展示序号、标题、页码、相关度 badge +- [x] 点击展开显示 excerpt 原文 + 作者/年份/DOI 元数据 +- [x] 相关度用颜色梯度区分(绿/黄/灰) +- [x] 0 条引用时隐藏引用区域 +- [x] 超长 excerpt(>500 字)折叠展示 + 展开按钮 +- [x] 加载分阶段动画:检索→骨架屏→文字渐入→完成 +- [x] 前后端字段统一为 `excerpt` +- [x] `MessageBubble` 使用 `React.memo()` +- [x] 流式过程中引用卡片随 citation 事件逐个淡入 + +--- + +### Phase 2: 片段重写与对比 + +**预估工作量**:3-4 天 + +#### 3.2.1 后端:Rewrite API + +**新文件**:`backend/app/api/v1/rewrite.py` + +**端点**:`POST /api/v1/chat/rewrite` + +```python +class RewriteRequest(BaseModel): + excerpt: str # 原文片段 + style: Literal["simplify", "academic", "translate_en", "translate_zh", "custom"] + custom_prompt: str | None = None + source_language: str = "auto" # 翻译时的源语言 + +class RewriteChunk(BaseModel): + delta: str # 流式增量 + +# SSE 流式响应 +# event: rewrite_delta, data: { delta: "..." } +# event: rewrite_end, data: { full_text: "..." } +``` + +**重写 Prompt 策略**: +- `simplify`:将学术语言简化为通俗表述,保留核心含义 +- `academic`:改写为符合学术规范的表述,保持原意 +- `translate_*`:中英互译,保留学术术语 +- `custom`:使用用户自定义 prompt + +**限制**: +- excerpt 最大长度:2000 字 +- 并发限制:每用户同时最多 3 个重写请求 +- 超时:30 秒 + +**审查修正:asyncio.to_thread 使用规则**: + +| 调用 | 是否需要 `asyncio.to_thread()` | 原因 | +|------|-------------------------------|------| +| LlamaIndex `retriever.retrieve()` | ✅ 需要 | 同步阻塞 API | +| LangChain `model.astream()` | ❌ 不需要 | 已是异步 | +| Rewrite 的 LLM 调用 | ❌ 不需要 | 使用 `LLMClient.chat_stream` 已异步 | + +Rewrite API 直接 `await llm.chat_stream()`,不要包在 `asyncio.to_thread()` 中。 + +**超时处理**:使用 `asyncio.wait_for` 包裹 LLM 调用,超时 30 秒后发送 `error` 事件。 + +**限流实现**:单用户本地部署使用全局 `asyncio.Semaphore(3)`;多用户场景引入 `slowapi` 中间件。 + +**请求校验(审查补充)**: + +```python +class RewriteRequest(BaseModel): + excerpt: str + style: Literal["simplify", "academic", "translate_en", "translate_zh", "custom"] + custom_prompt: str | None = None + source_language: str = "auto" + + @field_validator("excerpt") + @classmethod + def excerpt_max_length(cls, v: str) -> str: + if len(v) > 2000: + raise ValueError("excerpt must not exceed 2000 characters") + return v + + @model_validator(mode="after") + def custom_requires_prompt(self) -> "RewriteRequest": + if self.style == "custom" and not self.custom_prompt: + raise ValueError("custom_prompt required when style is custom") + return self +``` + +**文件变更清单**: +- `backend/app/api/v1/rewrite.py` — 新建 +- `backend/app/main.py` — 注册 rewrite router + +#### 3.2.2 前端:RewritePanel 组件 + +**新文件**:`frontend/src/components/playground/RewritePanel.tsx` + +**交互流程**: +1. 用户点击引用卡片上的"重写"按钮 +2. 弹出风格选择器(4 个预设 + 自定义输入) +3. 选择后开始 SSE 流式重写 +4. 重写结果以 Diff 对比视图展示(左原文 / 右重写) +5. 底部操作栏:复制、插入到编辑区(未来)、关闭 + +**Diff 库选择**:`react-diff-viewer-continued`(维护活跃,支持 `splitView`,中文友好用 word-level diff)。 + +```typescript +interface RewritePanelProps { + originalText: string; + paperTitle: string; + onClose: () => void; + onRewriteComplete?: (rewrittenText: string) => void; +} +``` + +> **审查洞察**:`rewrite-api.ts` 的 `streamRewrite()` 必须接受 `AbortSignal` 参数。关闭 RewritePanel 时通过 `AbortController.abort()` 中止 SSE 连接,避免后端继续生成。后端在 `StreamingResponse` 生成器中捕获 `asyncio.CancelledError` 以优雅退出。 + +**边缘情况**: +- 重写失败:Toast 提示 + 重试按钮 +- 并发重写:同时只允许 1 个进行中,其余按钮显示 loading +- 网络中断:部分结果保留,显示"已中断"标记 + +**新依赖**: +- `react-diff-viewer-continued` + +**文件变更清单**: +- `frontend/src/components/playground/RewritePanel.tsx` — 新建 +- `frontend/src/components/playground/RewriteStyleSelector.tsx` — 新建 +- `frontend/src/services/rewrite-api.ts` — 新建(SSE 流式调用) +- `frontend/src/components/playground/CitationCard.tsx` — 激活重写按钮 + +#### 3.2.3 验收标准(Phase 2) + +- [ ] 引用卡片上的"重写"按钮可用 +- [ ] 支持 5 种重写风格选择 +- [ ] 重写过程流式展示 +- [ ] Diff 对比视图正确展示原文与重写的差异 +- [ ] 重写结果可一键复制 +- [ ] 重写失败有 Toast + 重试 +- [ ] 同时只允许 1 个进行中的重写 + +--- + +### Phase 3: 内联标注与联动 + +**预估工作量**:2-3 天 + +#### 3.3.1 前端:InlineCitationTag 组件 + +**新文件**:`frontend/src/components/playground/InlineCitationTag.tsx` + +通过自定义 `react-markdown` 组件实现 `[1]`、`[2]` 的交互化。 + +**实现方式**:自定义 `remark` 或 `rehype` 插件解析 `[数字]` 模式,替换为 `` 组件。 + +> **审查洞察**:remark 在 MDAST 层操作,rehype 在 HAST 层操作。rehype 插件可能更简单——直接在 HTML AST 中找 `[N]` 文本节点并替换为 ``。此外,remark 插件链顺序可能影响 `remarkGfm` 和 `remarkMath`,需做回归测试。建议先尝试 rehype 方案。 + +```typescript +interface InlineCitationTagProps { + citationIndex: number; + citation?: Citation; + color: string; + onHover: (index: number) => void; + onClick: (index: number) => void; +} +``` + +**交互**: +- Hover:Radix `HoverCard` 弹出预览(论文标题 + excerpt 前 150 字) +- Click:平滑滚动到对应 `CitationCard`,高亮闪烁 +- 颜色:按 citation index 映射到 6 色调色板,同一来源颜色一致 + +**边缘情况**: +- 引用序号越界(如 `[3]` 但只有 2 条引用):渲染为普通灰色文本 `[3]`,不可交互 +- 流式过程中:引用标签随 citation 事件到达后才激活(之前显示为普通文本) + +**Remark 插件**:`frontend/src/lib/remark-citation.ts` + +```typescript +// remark 插件:将 [N] 模式转换为自定义 MDAST 节点 +// 然后在 react-markdown 的 components 中渲染为 InlineCitationTag +``` + +**颜色调色板**(6 色循环,色盲友好): + +```typescript +const CITATION_COLORS = [ + '#3B82F6', // blue + '#10B981', // emerald + '#F59E0B', // amber + '#EF4444', // red + '#8B5CF6', // violet + '#06B6D4', // cyan +]; +``` + +**文件变更清单**: +- `frontend/src/components/playground/InlineCitationTag.tsx` — 新建 +- `frontend/src/components/playground/CitationPopover.tsx` — 新建 +- `frontend/src/lib/remark-citation.ts` — 新建 remark 插件 +- `frontend/src/components/playground/MessageBubble.tsx` — 集成 remark 插件和颜色映射 + +#### 3.3.2 验收标准(Phase 3) + +- [ ] AI 回答中 `[1]`、`[2]` 渲染为彩色可交互标签 +- [ ] Hover 标签弹出论文标题 + excerpt 预览 +- [ ] 点击标签滚动到对应引用卡片并高亮 +- [ ] 不同来源用不同颜色区分 +- [ ] 越界引用序号显示为灰色不可交互文本 +- [ ] 流式过程中标签随 citation 到达逐步激活 + +--- + +### Phase 4: A2UI 富媒体渲染 + 并行 LLM 优化 + +**预估工作量**:5-7 天 + +> **重要发现**:浏览器端 grpc-web **不支持双向流**,只支持 unary 和 server streaming。因此调整策略:使用 ConnectRPC server streaming(兼容浏览器),而非头脑风暴中的双向流方案(see brainstorm: docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md)。 + +#### 3.4.1 A2UI 基础设施搭建 + +**新依赖**:`@a2ui/react`、`@a2ui/web-lib` + +**核心组件**: + +| 组件 | 职责 | +|------|------| +| `A2UIProvider` | 顶层 Provider,管理 MessageProcessor 实例 | +| `A2UISurface` | 渲染 A2UI surface 的容器 | +| `OmeletteCatalog` | 自定义组件目录注册 | + +**自定义组件目录**(审查后收缩为分批交付): + +**Phase 4A 最小集(3 个核心组件)**: + +| 组件 | 用途 | 触发场景 | 复杂度 | +|------|------|---------|--------| +| `CitationCard` | 展开式引用卡片 | 默认引用展示 | 低(Phase 1 已有) | +| `RewriteDiff` | 重写对比视图 | 重写操作结果 | 低(Phase 2 已有) | +| `StatsDashboard` | 统计概览面板 | "分析知识库概况" | 中 | + +**Phase 4B 扩展集(4 个增强组件,后续迭代)**: + +| 组件 | 用途 | 触发场景 | 复杂度 | +|------|------|---------|--------| +| `ComparisonTable` | 交互式论文对比表格 | "比较这几篇论文" | 高 | +| `PaperTimeline` | 可缩放研究时间线 | "展示研究发展历程" | 高 | +| `KnowledgeGraph` | 论文关系网络图 | "展示论文引用关系" | 高 | +| `ExportPanel` | 导出操作面板 | "导出这些引用" | 低 | + +> **架构审查建议**:7 个组件一次性交付偏多,且 ComparisonTable/PaperTimeline/KnowledgeGraph 需要额外图表库。建议先交付最小集验证 A2UI 可行性和 LLM 生成 JSON 的稳定性,再逐步扩展。 + +**SSE 事件扩展**: + +``` +event: a2ui_surface +data: { "surface_id": "s1", "messages": [...A2UI messages...] } +``` + +**降级机制**:A2UI JSON 解析失败或 schema 不匹配时,回退到 Markdown + 普通引用列表。 + +**文件变更清单**: +- `frontend/src/components/a2ui/A2UIProvider.tsx` — 新建 +- `frontend/src/components/a2ui/A2UISurface.tsx` — 新建 +- `frontend/src/components/a2ui/catalog/` — 7 个组件目录 +- `frontend/src/components/playground/MessageBubble.tsx` — A2UI 渲染分支 +- `backend/app/api/v1/chat.py` — 新增 `a2ui_surface` 事件 + +#### 3.4.2 ConnectRPC 并行 LLM(调整后方案) + +**关键调整**:由于浏览器不支持 gRPC 双向流,改用以下方案: + +| 层级 | 技术选择 | 说明 | +|------|---------|------| +| 传输协议 | ConnectRPC(Connect 协议) | 兼容 HTTP/1.1 和 HTTP/2,无需 Envoy 代理 | +| 后端 | `connect-python`(或 `grpcio` + grpc-web) | FastAPI 同端口共存,路径分离 | +| 前端 | `@connectrpc/connect-web` | 原生支持 server streaming | +| 流类型 | **Server Streaming**(非双向流) | 浏览器兼容的流式响应 | +| 并行化 | 后端 `asyncio.gather()` | 多知识库检索 + LLM 生成并行 | +| 降级 | SSE fallback | ConnectRPC 不可用时回退到现有 SSE | + +**Proto 定义**:`proto/chat.proto` + +```protobuf +syntax = "proto3"; +package omelette.chat.v1; + +service ChatService { + rpc StreamChat(ChatRequest) returns (stream ChatEvent); + rpc Rewrite(RewriteRequest) returns (stream RewriteEvent); +} + +message ChatRequest { + optional int64 conversation_id = 1; + repeated int64 knowledge_base_ids = 2; + string message = 3; + string model = 4; + string tool_mode = 5; +} + +message ChatEvent { + oneof event { + MessageStart message_start = 1; + CitationEvent citation = 2; + TextDelta text_delta = 3; + MessageEnd message_end = 4; + A2UISurface a2ui_surface = 5; + ErrorEvent error = 6; + } +} +``` + +**架构**: + +``` +Browser ──ConnectRPC──→ FastAPI (same port, /connect/* path) + ├── ChatService (server streaming) + └── asyncio.gather() parallel RAG + LLM + +Browser ──SSE──→ FastAPI (fallback, /api/v1/chat/stream) +``` + +**新依赖**: +- 后端:`connectrpc`(Python)或 `grpcio` + `grpcio-tools` +- 前端:`@connectrpc/connect`、`@connectrpc/connect-web`、`@bufbuild/protobuf` + +**文件变更清单**: +- `proto/chat.proto` — 新建 proto 定义 +- `backend/app/grpc/chat_service.py` — 新建 ConnectRPC 服务实现 +- `backend/app/main.py` — 挂载 ConnectRPC 路由 +- `frontend/src/services/connect-client.ts` — 新建 ConnectRPC 客户端 +- `frontend/src/services/chat-api.ts` — 添加 ConnectRPC 传输 + SSE 降级逻辑 + +#### 3.4.3 验收标准(Phase 4) + +- [ ] A2UI 渲染器正常工作,能渲染至少 3 种自定义组件 +- [ ] LLM 可根据查询意图动态输出 A2UI JSON 或 Markdown +- [ ] A2UI 解析失败时自动降级为 Markdown +- [ ] ConnectRPC server streaming 正常工作 +- [ ] 多知识库检索并行化(`asyncio.gather()`) +- [ ] ConnectRPC 不可用时自动降级为 SSE +- [ ] 多流合并:检索 + 生成并行,前端正确渲染 + +--- + +## 4. System-Wide Impact + +### 4.1 Interaction Graph + +``` +用户发送消息 + → PlaygroundPage.handleSend() + → streamChat() / ConnectRPC.StreamChat() + → _stream_chat() (backend) + → RAGService.query() [asyncio.to_thread] + → ChromaDB retriever.retrieve() + → Paper table lookup (批量查询) + → LLM.generate() [async] + → SSE events: citation*, text_delta*, message_end + → Optional: a2ui_surface events + → MessageBubble renders + → CitationCardList renders citations + → InlineCitationTag renders [N] markers + → A2UISurface renders rich components (Phase 4) + +用户点击重写 + → CitationCard.onRewrite() + → RewritePanel opens + → rewriteApi.streamRewrite() + → POST /api/v1/chat/rewrite (SSE) + → LLM.astream() [async, 无需 to_thread] + → RewritePanel renders diff view +``` + +### 4.2 Error Propagation + +| 错误源 | 错误类型 | 处理方式 | +|--------|---------|---------| +| RAG 检索失败 | 无结果/超时 | 返回空 citations,正常生成回答 | +| LLM 生成失败 | API 错误/超时 | SSE `error` 事件 → 前端 Toast | +| Paper 查询失败 | DB 错误 | citation 中 authors/year/doi 为 null,不影响展示 | +| 重写失败 | LLM 错误/限流 | Toast 提示 + 重试按钮 | +| A2UI 解析失败 | Schema 不匹配 | 降级为 Markdown 渲染 | +| ConnectRPC 失败 | 网络/服务错误 | 自动降级为 SSE | +| SSE body null | 流连接失败 | SSE body null 检查(see: codebase-quality-audit) | + +### 4.3 State Lifecycle Risks + +- **重写中断**:用户关闭 RewritePanel 时需 abort SSE 连接,避免后端继续生成 +- **多 tab 场景**:ConnectRPC 连接需 per-tab 管理,避免连接泄漏 +- **对话历史恢复**:citations(含 excerpt、元数据)已持久化在 `Message.citations` JSON 列中,恢复时直接读取 + +### 4.4 API Surface Parity + +| 接口 | 需要更新 | +|------|---------| +| `POST /api/v1/chat/stream` | 增强 citation 字段(authors/year/doi/chunk_type) | +| `POST /api/v1/chat/rewrite` | 新增(Phase 2) | +| `ConnectRPC ChatService` | 新增(Phase 4) | +| MCP `query_knowledge_base` tool | 同步增强 citation 字段 | + +--- + +## 5. Alternative Approaches Considered + +| 方案 | 优点 | 缺点 | 决策 | +|------|------|------|------| +| 独立工作面板 | 空间充裕 | 开发量大,偏离聊天范式 | 否决 | +| 新工具模式 | 架构一致 | 第一步过重 | 否决 | +| gRPC 双向流 | 全双工通信 | **浏览器不支持** | 否决,改用 server streaming | +| Vercel AI SDK | Generative UI 成熟 | 绑定 Next.js/Server Actions | 否决,使用 A2UI | +| asyncio 过渡方案 | 改动小 | 延迟优化有限 | 否决,直接上 ConnectRPC | + +--- + +## 6. Dependencies & Prerequisites + +### 6.1 新增前端依赖 + +```json +{ + "@a2ui/react": "^0.8.0", + "@a2ui/web-lib": "^0.8.0", + "@connectrpc/connect": "latest", + "@connectrpc/connect-web": "latest", + "@bufbuild/protobuf": "latest", + "react-diff-viewer-continued": "latest" +} +``` + +### 6.2 新增后端依赖 + +```toml +[project.optional-dependencies] +grpc = [ + "grpcio>=1.60", + "grpcio-tools>=1.60", + # 或 connectrpc (Python) +] +``` + +### 6.3 前置条件 + +- [ ] 确认 `@a2ui/react` 与 React 19 的兼容性(v0.8 文档标注 React 稳定) +- [ ] 确认 `@a2ui/react` 已发布到 NPM(文档提示可能尚未发布) +- [ ] 确认 `connect-python` 的稳定性(**实际为 Alpha**,非 Beta,需 POC 验证与 FastAPI 的集成) + +--- + +## 7. Risk Analysis & Mitigation + +| 风险 | 概率 | 影响 | 缓解策略 | +|------|------|------|---------| +| `@a2ui/react` 未发布或不兼容 React 19 | 中 | 高 | 先用原生 React 组件实现 CitationCard,A2UI 作为 Phase 4 增量 | +| `connect-python` **Alpha** 版有 breaking changes | 中 | 中 | 保留 SSE 作为主通道,ConnectRPC 作为可选增强,Phase 4 前做 POC | +| LLM 无法稳定生成 A2UI JSON | 高 | 中 | 后端做 schema 验证 + 自动降级为 Markdown | +| 大量引用(20+)渲染性能 | 中 | 中 | 默认显示前 5 条 + "显示更多",或虚拟列表 | +| 中文 Diff 显示不佳 | 低 | 低 | 使用 `diffWords` 而非 `diffLines`,必要时切换库 | + +--- + +## 8. Success Metrics + +| 指标 | 目标 | 测量方式 | +|------|------|---------| +| 引用信息完整度 | 100% citation 展示 excerpt + 元数据 | 手动验证 | +| 加载体验 | TTFB < 500ms,骨架屏 < 1s 出现 | Performance API | +| 重写响应时间 | TTFT < 2s | API 监控 | +| A2UI 渲染成功率 | > 90% | 降级计数 | +| 用户等待时间 | 并行后降低 30%+ | 端到端时间对比 | + +--- + +## 9. Acceptance Criteria (Overall) + +### Functional Requirements + +- [ ] 引用卡片展示 excerpt、元数据、相关度 +- [ ] 原文片段支持 5 种风格重写 + Diff 对比 +- [ ] 正文引用标记可交互:hover 预览、点击跳转 +- [ ] A2UI 可渲染自定义组件(至少 3 种) +- [ ] ConnectRPC server streaming 正常工作 +- [ ] 所有新功能支持 SSE 降级 + +### Non-Functional Requirements + +- [ ] 流式渲染无卡顿(60fps) +- [ ] 移动端响应式布局正常 +- [ ] 键盘可完整操作引用卡片和重写面板 +- [ ] 引用相关 aria 属性完备 +- [ ] 相关度颜色提供非颜色视觉提示(图标/文字) + +### Quality Gates + +- [ ] 前后端 TypeScript/Python 类型安全 +- [ ] 新组件有 Storybook 或 playground 展示 +- [ ] SSE body null 检查就位 +- [ ] `MessageBubble` 使用 `memo()` +- [ ] LlamaIndex 同步调用用 `asyncio.to_thread()` 包装 + +--- + +## 10. Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md](docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md) — 关键决策:渐进增强现有聊天界面、A2UI 第一步预埋、gRPC(调整为 ConnectRPC server streaming)、7 组件丰富集 + +### Internal References + +- 前置计划:`docs/plans/2026-03-11-feat-chat-streaming-citations-plan.md`(已完成) +- 引用渲染:`frontend/src/components/playground/MessageBubble.tsx:64-84` +- SSE 解析:`frontend/src/services/chat-api.ts:27-78` +- Citation 类型:`frontend/src/types/chat.ts:22-31` +- Citation 构建:`backend/app/api/v1/chat.py:95-107` +- RAG 查询:`backend/app/services/rag_service.py:202-211` +- 共享动画:`frontend/src/lib/motion.ts` + +### Institutional Learnings (docs/solutions/) + +- `docs/solutions/performance-issues/blocking-sync-calls-asyncio-to-thread.md` — LlamaIndex 同步调用必须 `asyncio.to_thread()` +- `docs/solutions/compound-issues/codebase-quality-audit-4-batch-remediation.md` — SSE body null 检查、MessageBubble memo()、N+1 查询优化 +- `docs/solutions/ui-bugs/comprehensive-ui-polish.md` — 骨架屏替代 spinner、动画从 motion.ts 引入 + +### External References + +- A2UI 协议:https://a2ui.org/ | https://github.com/google/A2UI +- ConnectRPC:https://connectrpc.com/docs/web/getting-started +- react-diff-viewer-continued:https://www.npmjs.com/package/react-diff-viewer-continued +- framer-motion v12:https://motion.dev/docs/react-quick-start diff --git a/docs/research/2026-03-12-a2ui-grpc-rich-chat-research.md b/docs/research/2026-03-12-a2ui-grpc-rich-chat-research.md new file mode 100644 index 0000000..e0f435c --- /dev/null +++ b/docs/research/2026-03-12-a2ui-grpc-rich-chat-research.md @@ -0,0 +1,506 @@ +# Research: A2UI, gRPC-Web & Rich Chat Best Practices (2026) + +Research conducted 2026-03-12. Covers Google A2UI protocol, gRPC-Web integration with FastAPI/React, and rich media chat interface patterns. + +--- + +## 1. Google A2UI 协议集成到 React 应用 + +### 1.1 概述 + +A2UI (Agent-to-UI) 是 Google 开源的 AI 驱动 UI 协议,允许 LLM 生成结构化 UI 描述,由客户端渲染为原生组件。v0.8 为当前稳定版。 + +### 1.2 @a2ui/react + @a2ui/web-lib 集成步骤 + +**安装:** +```bash +npm install @a2ui/react @a2ui/web-lib +``` + +**核心组件:** +- `MessageProcessor`:处理 JSONL 消息流,管理 surface 生命周期 +- `useA2UI()`:在任意组件中访问 MessageProcessor +- ``:渲染 A2UI surfaces + +**基础集成示例:** +```tsx +// App.tsx +import { Surface, useA2UI } from '@a2ui/react'; +import { MessageProcessor } from '@a2ui/web-lib'; + +function ChatWithA2UI() { + const processor = useMemo(() => new MessageProcessor(), []); + const [surfaceId] = useState('main'); + + return ( + + + + ); +} +``` + +**参考实现:** [React shell sample](https://github.com/google/A2UI/tree/main/samples/client/react/shell) + +### 1.3 Component Catalog(自定义组件目录)定义方式 + +**概念:** Catalog 是组件集合的契约,Agent 通过 `catalogId` 选择使用哪个 catalog,Client 注册对应实现。 + +**定义流程:** +1. 创建 catalog 定义(列出标准 + 自定义组件) +2. 实现自定义组件的 React 映射(如 `StockTicker` → ``) +3. 在 `beginRendering` 中通过 `catalogId` 指定 +4. Client 在 `a2uiClientCapabilities.supportedCatalogIds` 中声明支持 + +**安全注意:** +- 仅注册可信组件 +- 校验 agent 消息中的 component 属性 +- 不要将敏感 API 暴露给自定义组件 + +**文档:** [Custom Components](https://a2ui.org/guides/custom-components/) + +### 1.4 与 SSE 流式传输的结合方式 + +**消息格式:** JSONL(每行一个完整 JSON 对象),流式友好、易增量生成。 + +**典型消息序列(v0.8):** +```json +{"surfaceUpdate":{"surfaceId":"main","components":[...]}} +{"dataModelUpdate":{"surfaceId":"main","path":"/user","contents":[...]}} +{"beginRendering":{"surfaceId":"main","root":"root-component"}} +``` + +**SSE 集成伪代码:** +```ts +// 连接 SSE 并喂给 MessageProcessor +const eventSource = new EventSource('/api/a2ui/stream'); +eventSource.onmessage = (e) => { + const line = e.data; + try { + const msg = JSON.parse(line); + processor.processMessage(msg); + } catch (err) { + console.warn('Malformed A2UI message', err); + } +}; +``` + +**或使用 fetch + ReadableStream:** +```ts +const res = await fetch('/api/a2ui/stream', { method: 'POST', body: JSON.stringify(request) }); +const reader = res.body!.getReader(); +const decoder = new TextDecoder(); +let buffer = ''; +while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (line.trim()) processor.processMessage(JSON.parse(line)); + } +} +``` + +### 1.5 渐进渲染模式(Progressive Rendering) + +A2UI 原生支持渐进渲染:不必等完整响应,可边生成边渲染。 + +**流程:** +1. Agent 流式发送 `surfaceUpdate`(组件定义) +2. 随后发送 `dataModelUpdate`(数据) +3. 最后发送 `beginRendering`(渲染信号) +4. Client 收到 `beginRendering` 后从 root 开始渲染 + +**性能建议(来自 A2UI 文档):** +- 细粒度更新:只更新 `/user/name` 而非整个 `/` 模型 +- Diffing:比较新旧组件,仅更新变更属性 +- Batching:缓冲 16ms 内的更新,批量渲染 + +### 1.6 注意事项与常见坑 + +| 问题 | 处理方式 | +|------|----------| +| Schema Validation Failed | 校验消息格式与 A2UI spec 一致 | +| Invalid Data Path | 检查 JSON Pointer 语法与 data model 结构 | +| Invalid Component ID | 组件 ID 在 surface 内必须唯一 | +| Invalid Surface ID | 确保先收到 `beginRendering` 再渲染 | +| 网络中断 | 显示错误状态、重连,Agent 重发或恢复 | +| 畸形消息 | 跳过并继续,或发送 error 回 Agent | + +**版本说明:** +- v0.8:`surfaceUpdate`、`dataModelUpdate`、`beginRendering` +- v0.9:`updateComponents`、`updateDataModel`、`createSurface`,更扁平化 + +React renderer 在 v0.8 稳定,v0.9 支持待定。 + +### 1.7 相关文档链接 + +- [Client Setup](https://a2ui.org/guides/client-setup/) +- [Renderer Development](https://a2ui.org/guides/renderer-development/) +- [Custom Components](https://a2ui.org/guides/custom-components/) +- [Data Flow](https://a2ui.org/concepts/data-flow/) +- [Transports](https://a2ui.org/concepts/transports/) +- [A2UI GitHub](https://github.com/google/A2UI) + +--- + +## 2. gRPC-Web 集成到 FastAPI + React 应用 + +### 2.1 概述 + +gRPC-Web 允许浏览器通过 HTTP/1.1 或 HTTP/2 访问 gRPC 服务。**重要限制:浏览器不支持双向流**,仅支持 unary 和 server streaming。 + +### 2.2 FastAPI 中使用 grpcio 定义双向流服务 + +**Python 后端需单独运行 gRPC 服务**(FastAPI 与 gRPC 通常不同端口): + +```python +# chat.proto +syntax = "proto3"; + +service ChatService { + rpc StreamChat(stream ChatRequest) returns (stream ChatResponse); +} + +message ChatRequest { + string message = 1; + string conversation_id = 2; +} + +message ChatResponse { + string message = 1; + bool done = 2; +} +``` + +```python +# grpc_server.py +import grpc +from concurrent import futures +import chat_pb2 +import chat_pb2_grpc + +class ChatServicer(chat_pb2_grpc.ChatServiceServicer): + async def StreamChat(self, request_iter, context): + async for req in request_iter: + # 处理客户端消息 + yield chat_pb2.ChatResponse(message=f"Echo: {req.message}", done=False) + yield chat_pb2.ChatResponse(message="", done=True) + +async def serve(): + server = grpc.aio.server() + chat_pb2_grpc.add_ChatServiceServicer_to_server(ChatServicer(), server) + server.add_insecure_port('[::]:50051') + await server.start() + await server.wait_for_termination() +``` + +**双向流异步模式(需同时读写):** +```python +async def StreamChat(self, request_iter, context): + async def read_requests(): + async for req in request_iter: + # 处理客户端消息 + pass + + async def write_responses(): + for i in range(10): + await context.write(chat_pb2.ChatResponse(message=str(i))) + await asyncio.sleep(0.5) + + await asyncio.gather( + asyncio.create_task(read_requests()), + asyncio.create_task(write_responses()) + ) +``` + +### 2.3 前端:grpc-web vs @connectrpc/connect-web + +| 维度 | grpc-web | @connectrpc/connect-web | +|------|----------|--------------------------| +| 协议 | 仅 gRPC-Web | Connect 协议 + gRPC-Web 兼容 | +| 代理 | 需 Envoy 等代理 | Connect 协议可无需代理 | +| 格式 | 默认 binary | 默认 JSON,便于调试 | +| 工具链 | protoc + protoc-gen-grpc-web | buf + 现代工具链 | +| 维护 | 社区维护 | 活跃维护,约 930K 周下载 | + +**推荐:** 新项目优先考虑 `@connectrpc/connect-web`,支持 Connect 协议时无需 Envoy;若后端仅支持 gRPC,可用 `createGrpcWebTransport()`。 + +**Connect 协议示例:** +```ts +import { createConnectTransport } from "@connectrpc/connect-web"; + +const transport = createConnectTransport({ + baseUrl: "https://api.example.com", + useBinaryFormat: false, // JSON 便于调试 +}); +``` + +**gRPC-Web 协议示例:** +```ts +import { createGrpcWebTransport } from "@connectrpc/connect-web"; + +const transport = createGrpcWebTransport({ + baseUrl: "https://api.example.com", + useBinaryFormat: true, // gRPC-Web 常用 binary +}); +``` + +### 2.4 gRPC 双向流与单向流的区别和适用场景 + +| 模式 | 定义 | 浏览器支持 | 适用场景 | +|------|------|------------|----------| +| Unary | 请求 → 响应 | ✅ | 简单 CRUD、单次查询 | +| Server Streaming | 请求 → 流式响应 | ✅ | 聊天回复、日志流、实时推送 | +| Client Streaming | 流式请求 → 响应 | ❌ | 大文件上传、批量提交 | +| Bidirectional | 双向流 | ❌ | 实时聊天、游戏、协作编辑 | + +**浏览器端替代方案:** +- 需要双向实时:用 WebSocket 或 SSE + 轮询 +- 仅需服务端推送:用 Server Streaming + +### 2.5 与现有 HTTP API 共存的架构模式 + +**方案 A:端口分离** +``` +Browser → nginx (443) + ├─ /api/* → FastAPI (8000) # HTTP REST + └─ /grpc/* → Envoy (8080) → gRPC (50051) +``` + +**方案 B:Envoy 配置示例** +```yaml +# envoy.yaml +static_resources: + listeners: + - name: listener_0 + address: + socket_address: { address: 0.0.0.0, port_value: 8080 } + filter_chains: + - filters: + - name: envoy.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: auto + route_config: + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: { prefix: "/" } + route: { cluster: grpc_backend } + http_filters: + - name: envoy.grpc_web + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: grpc_backend + connect_timeout: 0.25s + type: LOGICAL_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: grpc_backend + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: localhost + port_value: 50051 +``` + +**nginx 反向代理:** +```nginx +location /grpc { + proxy_http_version 1.1; + proxy_pass http://localhost:8080; + proxy_set_header Connection ""; +} +``` + +### 2.6 错误处理和重连策略 + +```ts +// 重连示例 +let retries = 0; +const maxRetries = 3; + +async function connectWithRetry() { + while (retries < maxRetries) { + try { + const client = await createClient(transport); + retries = 0; + return client; + } catch (e) { + retries++; + await new Promise(r => setTimeout(r, 1000 * retries)); + } + } + throw new Error('Connection failed after retries'); +} +``` + +**错误类型:** +- `grpc-status`、`grpc-message` 响应头 +- 网络中断:检测 `fetch` 或 stream 错误,触发重连 + +### 2.7 降级方案(SSE fallback) + +当 gRPC-Web 不可用(如无 Envoy、代理故障)时,可回退到 SSE: + +```ts +async function getChatStream(request: ChatRequest) { + try { + return await grpcClient.streamChat(request); + } catch { + // Fallback to SSE + return fetch('/api/v1/chat/stream', { + method: 'POST', + body: JSON.stringify(request), + }).then(r => r.body!); + } +} +``` + +Omelette 当前已使用 SSE:`POST /api/v1/chat/stream`,可作为 gRPC 的降级端点。 + +### 2.8 相关文档链接 + +- [gRPC-Web Basics](https://grpc.io/docs/platforms/web/basics) +- [Connect Protocol](https://connectrpc.com/docs/web/choosing-a-protocol) +- [Connect Web Getting Started](https://connectrpc.com/docs/web/getting-started) +- [Envoy gRPC-Web](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/other_protocols/grpc.html) + +--- + +## 3. 富媒体聊天界面的最佳实践 + +### 3.1 概述 + +现代聊天界面不仅展示文本,还嵌入交互式组件(表格、图表、时间线),并支持流式 Markdown + 富媒体混合渲染。 + +### 3.2 聊天中嵌入交互式组件的 UX 最佳实践 + +**核心思路:** 将流式内容视为「组件序列化」而非纯文本。LLM 输出结构化描述,客户端解析并渲染为可交互组件。 + +**推荐模式(Vercel AI SDK):** +- 使用 `render()` 将 React 组件序列化进流 +- 使用 Server Actions 处理组件内交互(如按钮点击) +- 每个交互组件作为独立「状态岛」,避免全局重渲染 + +**UX 原则:** +- 内联引用:`[1][2]` 可点击展开 CitationCard +- 区分 AI 生成 vs 引用内容 +- 显示 AI 状态:`Thinking`、`Checking sources` +- 提供 Stop、Retry 按钮 +- 支持编辑前 prompt、对话分支 + +**交互式组件示例(伪代码):** +```tsx +// 服务端:注入交互式组件 +stream.append( + render( + + ) +); +``` + +**陷阱:** +- 不要让 LLM 直接生成 React 代码,易产生无效 JSX +- LLM 只生成描述性文本,组件由代码确定性注入 + +### 3.3 加载状态的分阶段展示(skeleton → content) + +**阶段:** +1. **骨架屏**:占位布局,显示大致结构 +2. **部分内容**:流式首 token 到达后开始渲染 +3. **完整内容**:流结束,移除 loading 状态 + +**实现示例:** +```tsx +{message.isStreaming ? ( +
+ {message.content} + +
+) : ( + {message.content} +)} +``` + +**Skeleton 占位:** +```tsx +{!messages.length && isLoading && ( +
+
+
+
+
+)} +``` + +**Next.js Suspense 模式:** +```tsx +}> + + +``` + +### 3.4 流式 Markdown + 富媒体混合渲染 + +**挑战:** +- 不完整 Markdown(未闭合代码块、半词) +- 需实时更新,避免闪烁 + +**最佳实践:** +- 缓存不完整 Markdown,等闭合再渲染代码块,或显示「streaming」指示 +- 每 50–100ms 批量更新 DOM,减少抖动 +- 优先展示首 token(TTFT < 500ms 感知更快) + +**推荐库:** +- **Streamdown**:专为流式 AI 内容设计,含 Shiki 代码高亮、KaTeX、流式动画 +- **react-markdown**:Omelette 已用,配合 `rehype-highlight`、`rehype-katex`、`remark-gfm` + +**混合渲染示例:** +```tsx +// 根据 part.type 渲染不同内容 +{message.parts.map((part) => { + if (part.type === 'text') return {part.text}; + if (part.type === 'chart') return ; + if (part.type === 'table') return ; + return null; +})} +``` + +### 3.5 常见陷阱与解决方案 + +| 问题 | 处理方式 | +|------|----------| +| 流中 await 阻塞 | 用 IIFE 并发执行,POST 立即返回 stream | +| 超时(如 Vercel 10s) | 立即开始流式输出,不等待完整响应 | +| 状态不同步 | Server Action 更新持久化存储,hydration 时拉取最新值 | +| 部分失败 | 保存已流内容,提供 Retry | + +### 3.6 相关文档链接 + +- [Beyond Text: Interactive Components in AI Chat](https://dev.to/programmingcentral/beyond-text-how-to-embed-interactive-ui-components-in-ai-chat-streams-5fic) +- [AI Chat UI Best Practices](https://dev.to/greedy_reader/ai-chat-ui-best-practices-designing-better-llm-interfaces-18jj) +- [Streaming LLM Responses: Real-Time UX](https://getathenic.com/blog/streaming-llm-responses-real-time-ux) +- [Streamdown](https://streamdown.ai/) +- [Next.js Loading UI and Streaming](https://nextjs.org/docs/14/app/building-your-application/routing/loading-ui-and-streaming) + +--- + +## 4. 与 Omelette 的衔接建议 + +- **A2UI**:若希望 Agent 生成结构化 UI(表单、卡片、图表),可评估 A2UI;当前 Chat 为纯文本 + Markdown,可先做 POC 验证 JSONL 流与 SSE 的兼容性。 +- **gRPC-Web**:若需低延迟、强类型 RPC,可引入;需注意浏览器不支持双向流,需 Envoy 或 Connect 协议。现有 SSE 可作为 fallback。 +- **富媒体**:当前已有 `react-markdown` + KaTeX + 代码高亮,可扩展 `message.parts` 支持 `chart`、`table` 等类型,参考 Vercel AI SDK 的 `render` 模式。 diff --git a/docs/research/2026-03-12-ai-sdk-playwright-research.md b/docs/research/2026-03-12-ai-sdk-playwright-research.md new file mode 100644 index 0000000..3690381 --- /dev/null +++ b/docs/research/2026-03-12-ai-sdk-playwright-research.md @@ -0,0 +1,537 @@ +# Research: Vercel AI SDK v5 & Playwright for Vite/React (2026) + +Research conducted 2026-03-12. Covers latest documentation for both technologies with concrete code examples. + +--- + +## 1. Vercel AI SDK v5 (@ai-sdk/react) + +### 1.1 Overview + +The AI SDK v5 introduces a **transport-based architecture** and no longer manages input state internally. You must manage input state separately (e.g., with `useState`). + +### 1.2 useChat Hook API and Configuration + +**Import:** +```tsx +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +``` + +**Basic usage with Vite/React (input state managed externally):** +```tsx +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +import { useState } from 'react'; + +export default function Chat() { + const [input, setInput] = useState(''); + const { messages, sendMessage, status, stop, error, regenerate } = useChat({ + transport: new DefaultChatTransport({ + api: '/api/chat', // or full URL for FastAPI: 'http://localhost:8000/api/chat' + headers: { + Authorization: 'Bearer token', // optional + }, + credentials: 'include', // optional, for cookies + body: { knowledgeBaseId: 'default' }, // extra body data + }), + }); + + return ( +
+ {messages.map((message) => ( +
+ {message.role}:{' '} + {message.parts + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('')} +
+ ))} + +
{ + e.preventDefault(); + if (input.trim()) { + sendMessage({ text: input }); + setInput(''); + } + }} + > + setInput(e.target.value)} + disabled={status !== 'ready'} + placeholder="Say something..." + /> + + {(status === 'submitted' || status === 'streaming') && ( + + )} +
+
+ ); +} +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `transport` | `ChatTransport` | Default: `DefaultChatTransport` with `/api/chat` | +| `id` | `string` | Unique chat ID (for persistence/resume) | +| `messages` | `UIMessage[]` | Initial messages | +| `onError` | `(error: Error) => void` | Error callback | +| `onFinish` | `(options) => void` | Called when stream finishes | +| `onData` | `(dataPart) => void` | Custom data parts | +| `resume` | `boolean` | Enable stream resumption after reload | +| `sendAutomaticallyWhen` | `(options) => boolean` | Auto-resubmit on tool calls | + +**Returns:** `messages`, `sendMessage`, `regenerate`, `stop`, `clearError`, `resumeStream`, `addToolOutput`, `setMessages`, `status`, `error` + +**Status values:** `'ready'` | `'submitted'` | `'streaming'` | `'error'` + +### 1.3 AI SDK Data Stream Protocol (Backend SSE Format) + +Your backend **must** emit Server-Sent Events (SSE) in this format. Set header: + +``` +x-vercel-ai-ui-message-stream: v1 +``` + +**Required stream parts (minimal chat):** + +``` +data: {"type":"start","messageId":"msg_abc123"} + +data: {"type":"text-start","id":"msg_abc123"} +data: {"type":"text-delta","id":"msg_abc123","delta":"Hello"} +data: {"type":"text-delta","id":"msg_abc123","delta":" world"} +data: {"type":"text-end","id":"msg_abc123"} + +data: {"type":"finish"} +data: [DONE] +``` + +**Full part reference:** + +| Part | Format | Purpose | +|------|--------|---------| +| `start` | `{"type":"start","messageId":"..."}` | Message start | +| `text-start` | `{"type":"text-start","id":"..."}` | Text block start | +| `text-delta` | `{"type":"text-delta","id":"...","delta":"..."}` | Incremental text | +| `text-end` | `{"type":"text-end","id":"..."}` | Text block end | +| `error` | `{"type":"error","errorText":"..."}` | Error in stream | +| `finish` | `{"type":"finish"}` | Message complete | +| `abort` | `{"type":"abort","reason":"..."}` | Stream aborted | +| `[DONE]` | literal `[DONE]` | Stream termination | + +Additional parts: `reasoning-start/delta/end`, `source-url`, `source-document`, `file`, `tool-input-start`, `tool-input-delta`, `tool-input-available`, `tool-output-available`, `start-step`, `finish-step`, `data-*` (custom). + +### 1.4 Error Handling and Retry + +**Error object and retry:** +```tsx +const { messages, sendMessage, error, regenerate, clearError } = useChat(); + +// Show error and retry button +{error && ( + <> +
Something went wrong.
+ + +)} + +// Disable submit when error + + +// Or clear error and replace last message before resubmit +function customSubmit(e: React.FormEvent) { + e.preventDefault(); + if (error != null) { + setMessages(messages.slice(0, -1)); + } + sendMessage({ text: input }); + setInput(''); +} +``` + +**onError callback:** +```tsx +useChat({ + onError: (error) => { + console.error(error); + // Log to monitoring, show toast, etc. + }, +}); +``` + +**onFinish with error info:** +```tsx +useChat({ + onFinish: ({ message, messages, isAbort, isDisconnect, isError, finishReason }) => { + if (isError) { + // Handle early stop due to error + } + }, +}); +``` + +**Resume after disconnect:** Use `resumeStream()` when a network error occurs during streaming. + +### 1.5 Integration with FastAPI Backends + +Your FastAPI backend must: + +1. Accept POST with `messages` (or your custom body shape) +2. Stream SSE with `x-vercel-ai-ui-message-stream: v1` +3. Emit the Data Stream Protocol parts + +**Minimal FastAPI example:** + +```python +# app/main.py +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sse_starlette.sse import EventSourceResponse +import json +import uuid + +app = FastAPI() +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]) + +async def stream_chat(messages: list): + """Yield SSE events in AI SDK Data Stream Protocol format.""" + msg_id = f"msg_{uuid.uuid4().hex}" + yield {"event": "message", "data": json.dumps({"type": "start", "messageId": msg_id})} + yield {"event": "message", "data": json.dumps({"type": "text-start", "id": msg_id})} + # Simulate streaming (replace with actual LLM call) + for chunk in ["Hello", " ", "from", " ", "FastAPI"]: + yield {"event": "message", "data": json.dumps({"type": "text-delta", "id": msg_id, "delta": chunk})} + yield {"event": "message", "data": json.dumps({"type": "text-end", "id": msg_id})} + yield {"event": "message", "data": json.dumps({"type": "finish"})} + yield {"event": "message", "data": "[DONE]"} + +@app.post("/api/chat") +async def chat(request: Request): + body = await request.json() + messages = body.get("messages", []) + return EventSourceResponse( + stream_chat(messages), + headers={"x-vercel-ai-ui-message-stream": "v1"}, + ) +``` + +**Dependencies:** +```bash +pip install fastapi uvicorn sse-starlette +``` + +**Frontend config (Vite proxy already in place):** +```tsx +// Your vite.config.ts proxies /api -> http://localhost:8000 +// So useChat with api: '/api/chat' will hit FastAPI +useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), +}); +``` + +### 1.6 React Setup with Vite + +**Install:** +```bash +npm install ai @ai-sdk/react +``` + +**Vite config (proxy to FastAPI):** +```ts +// vite.config.ts +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}); +``` + +**Text stream (simpler) vs Data stream:** +- For plain text only, use `TextStreamChatTransport` with `streamProtocol: 'text'` +- For tools, sources, custom data, use default Data Stream Protocol + +--- + +## 2. Playwright for Vite/React + +### 2.1 Setting Up Playwright with Vite React + +**Install:** +```bash +npm init playwright@latest +``` + +Choose: +- TypeScript +- Test directory: `e2e` or `tests/e2e` +- Add GitHub Actions: Yes + +**playwright.config.ts:** +```ts +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? '50%' : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); +``` + +**package.json scripts:** +```json +{ + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed" + } +} +``` + +### 2.2 Page Object Model (POM) + +**Page object class:** +```ts +// e2e/pages/ChatPage.ts +import { expect, type Locator, type Page } from '@playwright/test'; + +export class ChatPage { + readonly page: Page; + readonly input: Locator; + readonly submitButton: Locator; + readonly messages: Locator; + readonly stopButton: Locator; + + constructor(page: Page) { + this.page = page; + this.input = page.getByPlaceholder('Say something...'); + this.submitButton = page.getByRole('button', { name: 'Submit' }); + this.messages = page.locator('[data-testid="chat-messages"]'); + this.stopButton = page.getByRole('button', { name: 'Stop' }); + } + + async goto() { + await this.page.goto('/'); + } + + async sendMessage(text: string) { + await this.input.fill(text); + await this.submitButton.click(); + } + + async waitForResponse() { + await expect(this.messages.locator('.assistant-message').last()).toBeVisible({ timeout: 10000 }); + } +} +``` + +**Test using POM:** +```ts +// e2e/chat.spec.ts +import { test, expect } from '@playwright/test'; +import { ChatPage } from './pages/ChatPage'; + +test('user can send message and see response', async ({ page }) => { + const chatPage = new ChatPage(page); + await chatPage.goto(); + await chatPage.sendMessage('Hello'); + await chatPage.waitForResponse(); + await expect(chatPage.messages).toContainText('Hello'); +}); +``` + +### 2.3 Mock API vs Real Backend + +**Option A: Playwright `page.route()` (no MSW):** +```ts +test('shows mocked chat response', async ({ page }) => { + await page.route('**/api/chat', async (route) => { + if (route.request().method() === 'POST') { + // Simulate SSE stream + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode('data: {"type":"start","messageId":"m1"}\n\n')); + controller.enqueue(encoder.encode('data: {"type":"text-delta","id":"m1","delta":"Mocked reply"}\n\n')); + controller.enqueue(encoder.encode('data: {"type":"text-end","id":"m1"}\n\n')); + controller.enqueue(encoder.encode('data: {"type":"finish"}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + await route.fulfill({ + status: 200, + headers: { 'x-vercel-ai-ui-message-stream': 'v1', 'Content-Type': 'text/event-stream' }, + body: stream, + }); + } else { + await route.continue(); + } + }); + + await page.goto('/'); + await page.getByPlaceholder('Say something...').fill('Hi'); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page.locator('.assistant-message')).toContainText('Mocked reply'); +}); +``` + +**Option B: playwright-msw (MSW in Playwright):** +```bash +npm install playwright-msw --save-dev +``` + +```ts +// e2e/fixtures.ts +import { test as base } from '@playwright/test'; +import { createWorkerFixture } from 'playwright-msw'; +import { handlers } from '../src/mocks/handlers'; + +const test = base.extend({ + worker: createWorkerFixture(handlers), +}); + +export { test, expect }; +``` + +**Option C: Real backend in CI** +- Start FastAPI in CI before Playwright (e.g., `uvicorn app.main:app`) +- Or use `webServer` to start both frontend and backend + +### 2.4 CI Configuration for GitHub Actions + +**Generated workflow (playwright.yml):** +```yaml +# .github/workflows/playwright.yml +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 +``` + +**With backend (FastAPI) in CI:** +```yaml +- name: Start backend + run: | + pip install -r backend/requirements.txt + uvicorn app.main:app --host 0.0.0.0 --port 8000 & + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} +- name: Run Playwright tests + run: npx playwright test + env: + API_URL: http://localhost:8000 +``` + +### 2.5 Vitest vs Playwright: Component vs E2E + +| Aspect | Vitest | Playwright | +|--------|--------|------------| +| **Purpose** | Unit + component tests | E2E user flows | +| **Environment** | jsdom or browser (Vitest browser mode) | Real Chromium/Firefox/WebKit | +| **Speed** | Very fast | Slower, full app | +| **Scope** | Functions, components | Full app, real network | + +**Vitest for components (existing in Omelette):** +```ts +// Component test with Vitest + Testing Library +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChatInput } from './ChatInput'; + +test('submits on enter', async () => { + const onSubmit = vi.fn(); + render(); + await userEvent.type(screen.getByRole('textbox'), 'Hello{Enter}'); + expect(onSubmit).toHaveBeenCalledWith('Hello'); +}); +``` + +**MSW for Vitest (mock API in component tests):** +```ts +// src/test/setup.ts +import { setupServer } from 'msw/node'; +import { handlers } from './mocks/handlers'; + +export const server = setupServer(...handlers); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +``` + +**Recommended split:** +- **Vitest**: Unit tests, component tests (with jsdom or MSW for API) +- **Playwright**: E2E tests (with mocked API or real backend in CI) + +--- + +## References + +- [AI SDK useChat Reference](https://v5.ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) +- [AI SDK Stream Protocol](https://sdk.vercel.ai/docs/ai-sdk-ui/stream-protocol) +- [AI SDK Error Handling](https://sdk.vercel.ai/docs/ai-sdk-ui/error-handling) +- [AI SDK Transport](https://sdk.vercel.ai/docs/ai-sdk-ui/transport) +- [Playwright POM](https://playwright.dev/docs/pom) +- [Playwright Mock APIs](https://playwright.dev/docs/mock) +- [Playwright CI](https://playwright.dev/docs/ci-intro) +- [Vercel AI SDK Python/FastAPI Example](https://github.com/vercel/ai/tree/main/examples/next-fastapi) diff --git a/docs/research/2026-03-12-framework-docs-research.md b/docs/research/2026-03-12-framework-docs-research.md new file mode 100644 index 0000000..4c0ff7e --- /dev/null +++ b/docs/research/2026-03-12-framework-docs-research.md @@ -0,0 +1,471 @@ +# 框架与库文档研究 + +**日期**: 2026-03-12 +**范围**: @a2ui/react、@connectrpc/connect-web、React diff 库、framer-motion v12 + +--- + +## 1. @a2ui/react(A2UI React 渲染器) + +### 概述 + +A2UI 是 Google 开源的 Agent 生成 UI 协议,用于在客户端渲染 AI Agent 输出的结构化界面。React 渲染器基于 `@a2ui/web-lib` 共享库,负责协议解析、状态管理和数据绑定。 + +### 版本与包状态 + +| 包名 | 版本 | 状态 | 说明 | +|------|------|------|------| +| `@a2ui/react` + `@a2ui/web-lib` | v0.8 | 官方文档推荐 | 官方仓库有实现,NPM 发布状态待确认 | +| `@a2ui-sdk/react` | v0.4.0 | 社区稳定 | 支持 v0.8/v0.9,React ^19.0.0 | +| `@a2ui-bridge/react` | v0.1.0 | 社区 | useA2uiProcessor、Surface、ComponentMapping | +| `@a2ui-renderer/react` | 0.9.0-alpha.2 | 预发布 | 面向 v0.9 的无头工具包 | + +### 安装 + +```bash +# 官方(按 a2ui.org 文档) +npm install @a2ui/react @a2ui/web-lib + +# 社区替代(若官方包未发布) +npm install @a2ui-sdk/react @a2ui/web-lib +# 或 +npm install @a2ui-bridge/react @a2ui/web-lib +``` + +### 核心 API + +| API | 说明 | +|-----|------| +| `MessageProcessor` | 处理 A2UI JSONL 流,分发消息,管理 surface 生命周期 | +| `useA2UI()` | 在任意组件中获取 MessageProcessor 的 React hook | +| `Surface` | 渲染 A2UI surface 的 React 组件 | + +### 基本用法 + +```tsx +import { MessageProcessor, useA2UI, Surface } from '@a2ui/react'; +import '@a2ui/web-lib'; + +// 1. 创建 MessageProcessor +const processor = new MessageProcessor(); + +// 2. 通过 transport 接收消息(SSE / WebSocket / A2A) +// processor.processMessage(jsonLine); + +// 3. 在组件中使用 +function App() { + const a2ui = useA2UI(); + return ( + + ); +} +``` + +### 自定义组件注册 + +- 通过 **Custom Catalogs** 扩展:定义包含标准组件 + 自定义组件的 catalog +- Agent 在 `beginRendering` 中指定 `catalogId` +- 客户端在 `a2uiClientCapabilities.supportedCatalogIds` 中声明支持的 catalog +- 自定义组件支持 data binding(JSON Pointer)和 action 回调 + +```ts +// 定义 catalog,包含标准 + 自定义组件 +// 注册到 client,Agent 通过 surfaceUpdate 使用 +``` + +### 版本约束与注意事项 + +- **v0.8**:React 渲染器稳定;v0.9 支持尚未就绪 +- **v0.9**:消息重命名(`surfaceUpdate`→`updateComponents`,`beginRendering`→`createSurface`) +- 传输:支持 A2A、WebSocket、SSE +- 参考示例:[React shell](https://github.com/google/A2UI/tree/main/samples/client/react/shell) + +--- + +## 2. @connectrpc/connect-web(ConnectRPC) + +### 概述 + +ConnectRPC(前身 Buf Connect)是基于 Protocol Buffers 的 RPC 框架,支持 Connect、gRPC、gRPC-Web 协议。前端用 `@connectrpc/connect-web`,Python 后端用 `connect-python` 或 gRPC 实现。 + +### 安装 + +```bash +# 前端 +npm install @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuf + +# Python 后端 +pip install connect-python uvicorn +# 代码生成 +pip install connect-python[compiler] +``` + +### Proto 定义(含双向流) + +```protobuf +syntax = "proto3"; +package greet.v1; + +message GreetRequest { string name = 1; } +message GreetResponse { string greeting = 1; } + +// 双向流示例 +message ChatMessage { string text = 1; } +service ChatService { + rpc Chat(stream ChatMessage) returns (stream ChatMessage); +} +``` + +### buf.gen.yaml(前端 + Python) + +```yaml +# 前端 (buf.gen.yaml) +version: v2 +plugins: + - remote: buf.build/bufbuild/es + out: gen + - remote: buf.build/connectrpc/es + out: gen + +# Python (buf.gen.yaml) +version: v2 +plugins: + - remote: buf.build/protocolbuffers/python + out: . + - remote: buf.build/protocolbuffers/pyi + out: . + - remote: buf.build/connectrpc/python + out: . +``` + +### 前端基本用法 + +```ts +import { createClient } from "@connectrpc/connect"; +import { createConnectTransport } from "@connectrpc/connect-web"; +import { ElizaService } from "./gen/eliza_pb"; + +const transport = createConnectTransport({ + baseUrl: "https://api.example.com", +}); + +const client = createClient(ElizaService, transport); + +// Unary +const res = await client.say({ sentence: "Hello" }); + +// Server streaming +for await (const res of client.introduce({ name: "Joseph" })) { + console.log(res); +} +``` + +### React Hook 模式 + +```ts +import { useMemo } from "react"; +import { createClient, type Client } from "@connectrpc/connect"; +import { createConnectTransport } from "@connectrpc/connect-web"; +import type { DescService } from "@bufbuild/protobuf"; + +const transport = createConnectTransport({ + baseUrl: "https://api.example.com", +}); + +export function useClient(service: T): Client { + return useMemo(() => createClient(service, transport), [service]); +} +``` + +### Python 后端(connect-python) + +```python +from greet.v1.greet_connect import GreetService, GreetServiceASGIApplication +from greet.v1.greet_pb2 import GreetResponse + +class Greeter(GreetService): + async def greet(self, request, ctx): + return GreetResponse(greeting=f"Hello, {request.name}!") + +app = GreetServiceASGIApplication(Greeter()) +# uvicorn server:app +``` + +### 双向流 + +- **协议**:双向流需要 HTTP/2 +- **connect-python**:`AsyncConnectClient` 提供 `call_bidirectional_streaming`,支持 async 迭代器 +- **connect-web**:客户端方法返回 `AsyncIterable`,可用 `for await...of` 消费 + +### 版本约束与注意事项 + +- **connect-python**:Beta,1.0 可能引入 breaking changes +- **@connectrpc/connect-web**:v2.x 稳定,与 connect-python 互通 +- 若需 gRPC 生态兼容,可选用 gRPC-Web 协议 + +--- + +## 3. React 文本 Diff 库 + +### 库对比 + +| 库 | 安装 | 特点 | 中文支持 | +|----|------|------|----------| +| **react-diff-viewer** | `npm i react-diff-viewer` | 功能全、split/inline、语法高亮 | ✅ | +| **react-diff-viewer-continued** | `npm i react-diff-viewer-continued` | 原库 fork,持续维护 | ✅ | +| **react-diff-view** | `npm i react-diff-view` | 轻量、性能好 | ✅ | +| **diff2html** | `npm i diff2html` | 将 diff 转 HTML | ✅ | +| **diff** (jsdiff) | `npm i diff` | 底层 diff 算法 | ✅ | + +### 推荐组合 + +- **完整方案**:`react-diff-viewer-continued`(维护活跃) +- **轻量方案**:`react-diff-view` + `diff`(jsdiff) + +### 安装 + +```bash +# 推荐:维护版 +npm install react-diff-viewer-continued + +# 或 +npm install react-diff-viewer + +# 底层:仅需 diff 算法 +npm install diff +``` + +### 基本用法(react-diff-viewer) + +```tsx +import ReactDiffViewer from 'react-diff-viewer'; + +const oldCode = `这是原始中文文本`; +const newCode = `这是修改后的中文文本`; + + +``` + +### 使用 jsdiff 生成 diff + +```ts +import { diffLines, diffWords, diffChars } from 'diff'; + +const changes = diffLines(oldText, newText); +// 或词级 diff(适合中文) +const wordChanges = diffWords(oldText, newText); +``` + +### API 要点 + +| 属性 | 说明 | +|------|------| +| `oldValue` / `newValue` | 待比较字符串 | +| `splitView` | 左右分栏 vs 统一视图 | +| `disableWordDiff` | 关闭词级高亮 | +| `renderContent` | 自定义渲染(如语法高亮) | +| `styles` | 自定义样式 | + +### 版本约束与注意事项 + +- `react-diff-viewer` 原仓库已停止维护,建议用 `react-diff-viewer-continued` +- 中文建议:`diffWords` 或 `diffChars` 效果较好 +- 大 diff 可考虑 `react-diff-view` 以减小内存占用 + +--- + +## 4. framer-motion / motion v12(骨架屏与渐入动画) + +### 包名变更 + +- **framer-motion**:旧包名,仍可用 +- **motion**:新包名,v12 起推荐 + +```bash +# 新包(推荐) +npm install motion + +# 旧包 +npm install framer-motion +``` + +```ts +// 新包导入(motion/react 用于组件,stagger 从 motion 主包) +import { motion, AnimatePresence } from "motion/react" +import { stagger } from "motion" +``` + +### 安装 + +```bash +npm install motion +``` + +### AnimatePresence + 退出动画 + +```tsx +import { AnimatePresence, motion } from "motion/react"; + + + {show && ( + + )} + +``` + +- `mode="wait"`:先完成退出再进入 +- 子组件必须有唯一 `key` 以便追踪 + +### 骨架屏 → 内容过渡 + +```tsx +import { AnimatePresence, motion } from "motion/react"; + + + {loading ? ( + + ) : ( + + {content} + + )} + +``` + +### 列表 stagger 进入 + +```tsx +import { motion } from "motion/react"; +import { stagger } from "motion"; + +const listVariants = { + open: { + opacity: 1, + transition: { + delayChildren: stagger(0.1), + staggerChildren: 0.05, + }, + }, + closed: { + opacity: 0, + transition: { + delayChildren: stagger(0.01, { from: "last" }), + }, + }, +}; + +const itemVariants = { + open: { opacity: 1, y: 0 }, + closed: { opacity: 0, y: 20 }, +}; + + + {items.map((item) => ( + + {item} + + ))} + +``` + +### stagger 用法 + +```ts +import { stagger } from "motion/react"; + +// 在 variants 中 +transition: { + delayChildren: stagger(0.1), + // 或 + delayChildren: stagger(0.05, { from: "last" }), + delayChildren: stagger(0.1, { startDelay: 0.2 }), + delayChildren: stagger(0.1, { ease: "easeOut" }), +} +``` + +### 骨架屏 + layout 动画 + +```tsx + + + {loading ? ( + +
+ + ) : ( + + {content} + + )} + + +``` + +### API 要点 + +| API | 说明 | +|-----|------| +| `AnimatePresence` | 支持退出动画,需唯一 `key` | +| `stagger(duration, options)` | `delayChildren` 中用于 stagger | +| `layout` | 布局动画 | +| `mode="wait"` | 先退出再进入 | + +### 版本约束与注意事项 + +- `framer-motion` v12.x 与 `motion` 共享同一代码库 +- 与 `AnimatePresence` 配合时,避免在子 variant 中混用 `exit`/`hover`,可单独放在 `motion` 组件上 +- 自定义组件需用 `custom` 传索引,以支持 stagger 延迟 + +--- + +## 参考链接 + +| 主题 | 链接 | +|------|------| +| A2UI 客户端设置 | https://a2ui.org/guides/client-setup/ | +| A2UI 渲染器开发 | https://a2ui.org/guides/renderer-development/ | +| A2UI 自定义组件 | https://a2ui.org/guides/custom-components/ | +| Connect 使用客户端 | https://connectrpc.com/docs/web/using-clients | +| Connect Python 入门 | https://connectrpc.com/docs/python/getting-started | +| Connect Python 文档 | https://connect-python.readthedocs.io/ | +| Motion stagger | https://www.framer.com/motion/stagger/ | +| Motion AnimatePresence | https://www.framer.com/motion/animate-presence/ | +| react-diff-viewer-continued | https://www.npmjs.com/package/react-diff-viewer-continued | diff --git a/docs/solutions/performance-issues/2026-03-12-rag-rich-citation-performance-analysis.md b/docs/solutions/performance-issues/2026-03-12-rag-rich-citation-performance-analysis.md new file mode 100644 index 0000000..7c8eb3a --- /dev/null +++ b/docs/solutions/performance-issues/2026-03-12-rag-rich-citation-performance-analysis.md @@ -0,0 +1,283 @@ +--- +title: "知识库检索增强计划 — 性能瓶颈与优化分析" +date: 2026-03-12 +category: performance-issues +tags: + - rag + - chat + - streaming + - citation + - rewrite + - a2ui + - connectrpc +components: + - backend/app/api/v1/chat.py + - backend/app/services/rag_service.py + - frontend/src/components/playground/MessageBubble.tsx + - frontend/src/pages/PlaygroundPage.tsx +severity: high +origin: docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md +--- + +# 知识库检索增强计划 — 性能瓶颈与优化分析 + +## 1. Performance Summary + +本分析针对 `docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md` 中的知识库检索增强计划,从 Performance Oracle 视角评估六个性能关注点的严重程度、优化建议、基准目标与监控指标。 + +**当前架构要点**: +- 后端:FastAPI + SQLAlchemy async + SQLite (WAL) + LlamaIndex + ChromaDB +- 前端:React 19 + framer-motion + react-markdown +- 传输:SSE(当前)→ ConnectRPC(Phase 4) + +**关键发现**: +- 流式渲染(每 token 全量 Markdown 重解析)是**最高优先级**瓶颈 +- Citation 批量查询已纳入计划,SQLite WAL 可支撑 10 条并发读 +- Rewrite 与 A2UI 需关注主线程阻塞与解析开销 +- ConnectRPC 的收益主要体现在多路复用与连接管理,非首屏延迟 + +--- + +## 2. 各关注点严重程度与优化建议 + +### 2.1 Citation 数据增强(Paper 表查询) + +| 维度 | 评估 | +|------|------| +| **严重程度** | 中 | +| **当前状态** | 计划 Phase 1 将引入 Paper 表查询以补充 authors/year/doi;若按计划草案逐条 `db.get(Paper, id)` 则存在 N+1 | +| **计划中的优化** | 批量查询 `select(Paper).where(Paper.id.in_(paper_ids))` | +| **SQLite 并发** | WAL 模式下,读操作可并发;10 条 Paper 的 `IN` 查询单次执行,无并发瓶颈 | + +**优化建议**: + +1. **必须**:在 `_stream_chat()` 中实现批量 Paper 查询,避免 N+1 + ```python + paper_ids = [s.get("paper_id") for s in all_sources if s.get("paper_id")] + papers = {} + if paper_ids: + result = await db.execute(select(Paper).where(Paper.id.in_(paper_ids))) + for p in result.scalars().unique().all(): + papers[p.id] = p + for i, src in enumerate(all_sources, 1): + paper = papers.get(src.get("paper_id")) if src.get("paper_id") else None + citation = {..., "authors": paper.authors if paper else None, ...} + ``` + +2. **可选**:若 ChromaDB 索引时已写入 authors/year/doi 到 metadata,可优先从 metadata 读取,减少 Paper 表访问 + +3. **SQLite 注意**:单连接内批量 `IN` 查询无问题;多用户并发时,WAL 支持多读单写,10 条读查询不会成为瓶颈 + +**基准目标**:Citation 构建(含 Paper 批量查询)< 50ms + +--- + +### 2.2 流式渲染(react-markdown + MessageBubble) + +| 维度 | 评估 | +|------|------| +| **严重程度** | 高 | +| **根因** | 每个 `text_delta` 触发 `setMessages`,导致当前消息的 `MessageBubble` 重渲染;`ReactMarkdown` 对**完整 content** 重新解析(remark + rehype 全链路),每 token 一次 | +| **影响** | 长回答(500+ token)时,500+ 次完整 Markdown 解析;remark-gfm、remark-math、rehype-katex、rehype-highlight 均为 CPU 密集型 | +| **15+ 引用卡片** | 每次 content 更新都会重渲染整个 CitationCardList | + +**优化建议**: + +1. **流式节流(Throttle)** — 高优先级 + - 将 `text_delta` 的 state 更新节流到每 50–100ms 或每 N 个 token(如 5–10) + - 使用 `useDeferredValue` 或自定义 `requestAnimationFrame` 批处理 + ```typescript + // 示例:节流到每 80ms + const throttledContent = useDeferredValue(content); + // 或:在 handleSend 中累积 buffer,每 80ms 或每 8 token 更新一次 + ``` + +2. **增量渲染 Markdown** — 中优先级 + - 考虑 `react-markdown` 的 `allowDangerousHtml` 或分段渲染:已稳定部分用静态 HTML,仅尾部用 Markdown + - 或采用支持增量/流式解析的库(如 `marked` + 自定义流式渲染) + +3. **CitationCardList 虚拟化** — 计划已提及 + - 15+ 引用时使用 `@tanstack/react-virtual`,仅渲染可视区域 + - 默认展示前 5 条 +「显示更多」可降低初始渲染成本 + +4. **MessageBubble memo** — 已就绪 + - `memo(MessageBubble)` 已存在,可防止兄弟消息在 content 更新时重渲染 + - 确保 `citations` 使用稳定引用(如 `useMemo` 包装),避免无意义重渲染 + +5. **framer-motion 动画** + - 流式阶段避免对整段内容做复杂 layout 动画 + - 骨架屏、引用卡片淡入使用 `opacity`/`transform`,避免触发布局重算 + +**基准目标**: +- 流式过程中主线程 Long Task < 50ms(Chrome Performance) +- 60fps 滚动与输入响应 +- 首 token 到首屏渲染 < 100ms + +--- + +### 2.3 Rewrite SSE 流 + +| 维度 | 评估 | +|------|------| +| **严重程度** | 中 | +| **关注点** | 每用户 3 个并发重写、LLM 延迟、Diff 计算 CPU | + +**优化建议**: + +1. **并发限制** — 计划已限定每用户 3 个,合理;建议用 `asyncio.Semaphore` 或 Redis 分布式限流(多实例时) + +2. **LLM 延迟** — 无法显著优化,可做: + - 流式输出时尽早展示首 token(TTFT) + - 超时 30s 已合理 + +3. **Diff 计算** — 高优先级 + - `react-diff-viewer-continued` 的 `diffWords` 对长文本可能阻塞主线程 + - 将 Diff 计算移到 Web Worker,或使用 `requestIdleCallback` 延迟到空闲时 + - 超长 excerpt(>500 字)可先截断展示,或分块 diff + +4. **重写流式** — 与主聊天流类似,对 `rewrite_delta` 做节流更新 + +**基准目标**:TTFT < 2s;Diff 渲染不阻塞主线程 > 100ms + +--- + +### 2.4 A2UI JSON 解析 + +| 维度 | 评估 | +|------|------| +| **严重程度** | 中 | +| **关注点** | 大 JSON payload 解析、流式 A2UI 增量渲染、自定义组件初始化 | + +**优化建议**: + +1. **JSON 解析** + - 大 payload(>50KB)使用 `JSON.parse` 的流式替代(如 `streaming-json-parser`)或分块解析 + - 或在后端分片发送 `a2ui_surface`,避免单次超大 JSON + +2. **流式 A2UI** + - 若 A2UI 消息可增量追加,采用增量解析 + 增量挂载组件,避免整块替换 + +3. **组件初始化** + - 7 个自定义组件按需懒加载:`React.lazy` + `Suspense` + - 避免首屏加载全部 A2UI 组件 + +4. **降级** — 计划已包含解析失败时回退 Markdown,可补充:解析超时(如 500ms)也触发降级 + +**基准目标**:A2UI JSON 解析 < 100ms;组件挂载 < 200ms + +--- + +### 2.5 ConnectRPC vs SSE + +| 维度 | 评估 | +|------|------| +| **严重程度** | 低(对首屏延迟);中(对多 tab/多流场景) | +| **HTTP/2 多路复用** | 同一连接多流,减少连接数;对单次聊天请求,收益有限 | +| **连接建立** | ConnectRPC 需建立连接,首请求可能略慢于 SSE;可预热连接 | +| **多 tab** | 每 tab 独立连接,需管理连接生命周期,避免泄漏 | + +**优化建议**: + +1. **保留 SSE 降级** — 计划已包含,确保不可用时自动回退 + +2. **连接预热** — 页面加载后预建立 ConnectRPC 连接,首条消息时复用 + +3. **多 tab** — 使用 `visibilitychange` 或 `pagehide` 时关闭连接,避免悬空连接 + +4. **优先级** — 先解决流式渲染与 Citation 批量查询,ConnectRPC 作为 Phase 4 增强,非阻塞项 + +**基准目标**:ConnectRPC 首字节延迟与 SSE 差异 < 100ms;多 tab 无连接泄漏 + +--- + +### 2.6 内联引用解析(remark 插件 [N]) + +| 维度 | 评估 | +|------|------| +| **严重程度** | 中高 | +| **根因** | 自定义 remark 插件在**每次** Markdown 解析时执行;流式场景下每 token 触发一次完整解析,插件中的正则 `[N]` 匹配会重复执行 | +| **正则开销** | 单次 `/\d+/` 或 `\[(\d+)\]/g` 对短文本可忽略,但叠加 remark/rehype 全链路,整体解析成本显著 | + +**优化建议**: + +1. **与流式节流联动** — 节流 content 更新后,remark 插件调用频率自然降低 + +2. **插件轻量化** + - 插件内仅做必要 AST 变换,避免复杂计算 + - 正则预编译:`const CITE_RE = /\[(\d+)\]/g;` + +3. **后处理方案** — 若 remark 插件难以轻量化,可考虑:Markdown 渲染为 HTML 后,用 `replace` 或 DOM 操作将 `[1]` 替换为 `InlineCitationTag`,避免参与 remark 解析链(需评估与 react-markdown 的兼容性) + +4. **流式阶段简化** — 流式过程中可暂时不解析 `[N]`,仅渲染纯文本;`message_end` 后再启用完整解析 + +**基准目标**:单次 remark 插件执行 < 5ms;与节流配合后整体解析 < 20ms/次 + +--- + +## 3. 性能基准目标汇总 + +| 指标 | 目标 | 测量方式 | +|------|------|---------| +| Citation 构建(含 Paper 批量查询) | < 50ms | 后端日志/APM | +| 首 token 到首屏渲染(TTFT) | < 500ms | Performance API / 自定义埋点 | +| 流式过程主线程 Long Task | < 50ms | Chrome Performance 面板 | +| 流式帧率 | 60fps | requestAnimationFrame + FPS 监控 | +| 重写 TTFT | < 2s | API 监控 | +| Diff 渲染阻塞 | < 100ms | Performance API | +| A2UI JSON 解析 | < 100ms | 自定义埋点 | +| ConnectRPC 首字节 vs SSE | 差异 < 100ms | 端到端对比 | + +--- + +## 4. 监控指标建议 + +### 4.1 后端 + +| 指标 | 说明 | 实现 | +|------|------|------| +| `chat_citation_build_ms` | Citation 构建耗时(含 Paper 查询) | 计时 + 日志/指标 | +| `chat_rag_query_ms` | RAG 检索耗时 | 已有 `asyncio.to_thread`,可加计时 | +| `chat_llm_ttft_ms` | LLM 首 token 延迟 | 记录首个 token 时间戳 | +| `rewrite_request_active` | 当前进行中的重写请求数 | 每用户 Semaphore 计数 | +| `rewrite_llm_ttft_ms` | 重写 LLM 首 token 延迟 | 同上 | + +### 4.2 前端 + +| 指标 | 说明 | 实现 | +|------|------|------| +| `stream_update_throttle_rate` | 节流后实际 state 更新频率 | 计数器 | +| `markdown_parse_ms` | 单次 Markdown 解析耗时 | `performance.now()` 包裹 | +| `long_task_count` | Long Task(>50ms)次数 | PerformanceObserver | +| `citation_card_render_count` | 渲染的引用卡片数 | 开发模式日志 | +| `a2ui_parse_ms` | A2UI JSON 解析耗时 | 同上 | + +### 4.3 业务 + +| 指标 | 说明 | +|------|------| +| 端到端首 token 时间 | 用户发送到首字显示 | +| 流式完成时间 | 用户发送到 `message_end` | +| A2UI 降级率 | 解析失败回退 Markdown 的比例 | + +--- + +## 5. 推荐实施优先级 + +| 优先级 | 项目 | 预期收益 | 实现复杂度 | +|--------|------|---------|-----------| +| P0 | 流式 content 更新节流 | 消除卡顿,降低 CPU | 低 | +| P0 | Citation 批量 Paper 查询 | 消除 N+1 | 低 | +| P1 | CitationCardList 虚拟化(15+) | 大量引用时流畅 | 中 | +| P1 | Diff 计算 offload(Worker/requestIdleCallback) | 避免主线程阻塞 | 中 | +| P2 | A2UI 组件懒加载 | 减少首屏 bundle | 低 | +| P2 | 内联引用流式阶段简化 | 降低解析频率 | 中 | +| P3 | ConnectRPC 连接预热 | 改善首请求延迟 | 低 | + +--- + +## 6. Related Documents + +- **计划来源**:`docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md` +- **制度知识**:`docs/solutions/compound-issues/codebase-quality-audit-4-batch-remediation.md`(MessageBubble memo、N+1) +- **制度知识**:`docs/solutions/performance-issues/blocking-sync-calls-asyncio-to-thread.md`(RAG/LLM 同步调用) +- **数据库**:`backend/app/database.py`(SQLite WAL) diff --git a/frontend/package.json b/frontend/package.json index c43f601..0cfb3dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.0.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/playground/CitationCard.tsx b/frontend/src/components/playground/CitationCard.tsx new file mode 100644 index 0000000..41d13c2 --- /dev/null +++ b/frontend/src/components/playground/CitationCard.tsx @@ -0,0 +1,184 @@ +import { memo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import ReactMarkdown from "react-markdown"; +import { ChevronDown, ExternalLink, Copy } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { Citation } from "@/types/chat"; + +const RELEVANCE_STYLES = { + high: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400", + medium: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", + low: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400", +} as const; + +const EXCERPT_PREVIEW_LENGTH = 300; +const EXCERPT_MAX_DISPLAY = 500; + +export const CITATION_COLORS = [ + "#3B82F6", + "#10B981", + "#F59E0B", + "#EF4444", + "#8B5CF6", + "#06B6D4", +] as const; + +export type CitationColorIndex = 0 | 1 | 2 | 3 | 4 | 5; + +function getRelevanceLevel(score: number) { + if (score > 0.8) return "high"; + if (score > 0.5) return "medium"; + return "low"; +} + +function formatAuthors(authors: string[] | string | null | undefined): string { + if (!authors) return ""; + if (typeof authors === "string") return authors; + if (authors.length <= 2) return authors.join(", "); + return `${authors[0]} et al.`; +} + +interface CitationCardProps { + citation: Citation; + colorIndex: CitationColorIndex; + isExpanded: boolean; + onToggle: () => void; +} + +function CitationCard({ + citation, + colorIndex, + isExpanded, + onToggle, +}: CitationCardProps) { + const { t } = useTranslation(); + const [showFullExcerpt, setShowFullExcerpt] = useState(false); + const level = getRelevanceLevel(citation.relevance_score); + const color = CITATION_COLORS[colorIndex]; + const authors = formatAuthors(citation.authors); + const needsTruncation = citation.excerpt.length > EXCERPT_MAX_DISPLAY; + const displayExcerpt = + needsTruncation && !showFullExcerpt + ? citation.excerpt.slice(0, EXCERPT_PREVIEW_LENGTH) + "..." + : citation.excerpt; + + const handleCopyExcerpt = () => { + navigator.clipboard.writeText(citation.excerpt); + }; + + return ( +
+ + + + {isExpanded && ( + +
+ {citation.excerpt && ( +
+
+ {displayExcerpt} +
+
+ {needsTruncation && ( + + )} + +
+
+ )} + +
+ {authors && {authors}} + {citation.year && {citation.year}} + {citation.doi && ( + e.stopPropagation()} + className="inline-flex items-center gap-0.5 text-primary hover:underline" + > + DOI + + + )} +
+
+
+ )} +
+
+ ); +} + +export default memo(CitationCard); diff --git a/frontend/src/components/playground/CitationCardList.tsx b/frontend/src/components/playground/CitationCardList.tsx new file mode 100644 index 0000000..7f0bc7e --- /dev/null +++ b/frontend/src/components/playground/CitationCardList.tsx @@ -0,0 +1,81 @@ +import { memo, useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { ChevronDown } from "lucide-react"; +import { staggerContainer, staggerItem } from "@/lib/motion"; +import CitationCard from "./CitationCard"; +import type { CitationColorIndex } from "./CitationCard"; +import type { Citation } from "@/types/chat"; + +const INITIAL_DISPLAY_COUNT = 5; + +interface CitationCardListProps { + citations: Citation[]; + isStreaming?: boolean; +} + +function CitationCardList({ citations, isStreaming }: CitationCardListProps) { + const { t } = useTranslation(); + const [expandedIndices, setExpandedIndices] = useState>(new Set()); + const [showAll, setShowAll] = useState(false); + + const toggleExpand = useCallback((index: number) => { + setExpandedIndices((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }, []); + + const displayCitations = useMemo( + () => (showAll ? citations : citations.slice(0, INITIAL_DISPLAY_COUNT)), + [citations, showAll], + ); + + const hasMore = citations.length > INITIAL_DISPLAY_COUNT; + + if (citations.length === 0) return null; + + return ( +
+

+ {t("playground.citations")} ({citations.length}) +

+ + {displayCitations.map((c) => ( + + toggleExpand(c.index)} + /> + + ))} + + + {hasMore && !showAll && !isStreaming && ( + + )} +
+ ); +} + +export default memo(CitationCardList); diff --git a/frontend/src/components/playground/MessageBubble.tsx b/frontend/src/components/playground/MessageBubble.tsx index 886b402..083cccc 100644 --- a/frontend/src/components/playground/MessageBubble.tsx +++ b/frontend/src/components/playground/MessageBubble.tsx @@ -1,19 +1,22 @@ -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import rehypeHighlight from 'rehype-highlight'; -import { User, Bot, FileText } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import type { Citation } from '@/types/chat'; +import { memo } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import rehypeHighlight from "rehype-highlight"; +import { User, Bot } from "lucide-react"; +import { cn } from "@/lib/utils"; +import CitationCardList from "./CitationCardList"; +import MessageLoadingStages from "./MessageLoadingStages"; +import type { LoadingStage } from "./MessageLoadingStages"; +import type { Citation } from "@/types/chat"; interface MessageBubbleProps { - role: 'user' | 'assistant'; + role: "user" | "assistant"; content: string; citations?: Citation[]; isStreaming?: boolean; + loadingStage?: LoadingStage; } function MessageBubble({ @@ -21,18 +24,20 @@ function MessageBubble({ content, citations, isStreaming, + loadingStage, }: MessageBubbleProps) { - const { t } = useTranslation(); - const isUser = role === 'user'; + const isUser = role === "user"; + const effectiveStage = loadingStage ?? (isStreaming ? "generating" : "complete"); + const showLoading = isStreaming && !content && effectiveStage !== "complete"; return ( -
+
{isUser ? : } @@ -40,46 +45,42 @@ function MessageBubble({
{isUser ? (

{content}

) : ( -
- - {content} - - {isStreaming && ( - + <> + {showLoading && ( + )} -
- )} - {citations && citations.length > 0 && ( -
-

{t('playground.citations')}

-
    - {citations.map((c) => ( -
  • + - - - [{c.index}] {c.paper_title} - {c.page_number > 0 && ` (p.${c.page_number})`} - -
  • - ))} -
-
+ {content} + + {isStreaming && ( + + )} +
+ )} + + + )}
diff --git a/frontend/src/components/playground/MessageLoadingStages.tsx b/frontend/src/components/playground/MessageLoadingStages.tsx new file mode 100644 index 0000000..5d9dc89 --- /dev/null +++ b/frontend/src/components/playground/MessageLoadingStages.tsx @@ -0,0 +1,76 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import { motion, AnimatePresence } from "framer-motion"; +import { Search, BookOpen, Sparkles } from "lucide-react"; +import { fadeIn } from "@/lib/motion"; + +export type LoadingStage = "searching" | "citations" | "generating" | "complete"; + +interface MessageLoadingStagesProps { + stage: LoadingStage; + citationCount?: number; +} + +const STAGE_CONFIG = { + searching: { + icon: Search, + labelKey: "playground.loading.searching", + defaultLabel: "正在检索文献...", + color: "text-blue-500", + pulse: true, + }, + citations: { + icon: BookOpen, + labelKey: "playground.loading.citations", + defaultLabel: "已找到相关文献", + color: "text-emerald-500", + pulse: true, + }, + generating: { + icon: Sparkles, + labelKey: "playground.loading.generating", + defaultLabel: "正在生成回答...", + color: "text-primary", + pulse: false, + }, + complete: { + icon: Sparkles, + labelKey: "", + defaultLabel: "", + color: "text-primary", + pulse: false, + }, +} as const; + +function MessageLoadingStages({ stage, citationCount }: MessageLoadingStagesProps) { + const { t } = useTranslation(); + + if (stage === "complete") return null; + + const config = STAGE_CONFIG[stage]; + const Icon = config.icon; + + const label = stage === "citations" && citationCount + ? `${t(config.labelKey, { defaultValue: config.defaultLabel })} (${citationCount})` + : t(config.labelKey, { defaultValue: config.defaultLabel }); + + return ( + + +
+ +
+ {label} +
+
+ ); +} + +export default memo(MessageLoadingStages); diff --git a/frontend/src/pages/PlaygroundPage.tsx b/frontend/src/pages/PlaygroundPage.tsx index 527b2c6..ea458f8 100644 --- a/frontend/src/pages/PlaygroundPage.tsx +++ b/frontend/src/pages/PlaygroundPage.tsx @@ -20,6 +20,8 @@ import MessageBubble from '@/components/playground/MessageBubble'; import { streamChat, conversationApi } from '@/services/chat-api'; import { projectApi } from '@/services/api'; import type { ToolMode, Citation } from '@/types/chat'; +import { isCitation, normalizeCitation } from '@/types/chat'; +import type { LoadingStage } from '@/components/playground/MessageLoadingStages'; interface LocalMessage { id: string; @@ -27,6 +29,7 @@ interface LocalMessage { content: string; citations?: Citation[]; isStreaming?: boolean; + loadingStage?: LoadingStage; } export default function PlaygroundPage() { @@ -83,6 +86,24 @@ export default function PlaygroundPage() { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); + const pendingDeltaRef = useRef(''); + const flushTimerRef = useRef>(); + const assistantIdRef = useRef(''); + + const flushDelta = useCallback(() => { + if (!pendingDeltaRef.current || !assistantIdRef.current) return; + const delta = pendingDeltaRef.current; + const aid = assistantIdRef.current; + pendingDeltaRef.current = ''; + setMessages((prev) => + prev.map((m) => + m.id === aid + ? { ...m, content: m.content + delta, loadingStage: 'generating' as LoadingStage } + : m, + ), + ); + }, []); + const handleSend = useCallback( async (message: string) => { const userMsg: LocalMessage = { @@ -96,8 +117,12 @@ export default function PlaygroundPage() { content: '', citations: [], isStreaming: true, + loadingStage: 'searching', }; + assistantIdRef.current = assistantMsg.id; + pendingDeltaRef.current = ''; + setMessages((prev) => [...prev, userMsg, assistantMsg]); setIsStreaming(true); @@ -118,23 +143,37 @@ export default function PlaygroundPage() { for await (const event of gen) { if (event.event === 'text_delta') { const delta = (event.data as { delta: string }).delta; - setMessages((prev) => - prev.map((m) => - m.id === assistantMsg.id - ? { ...m, content: m.content + delta } - : m, - ), - ); + pendingDeltaRef.current += delta; + if (!flushTimerRef.current) { + flushTimerRef.current = setTimeout(() => { + flushTimerRef.current = undefined; + flushDelta(); + }, 80); + } } else if (event.event === 'citation') { - const citation = event.data as unknown as Citation; + if (!isCitation(event.data)) { + console.warn('Invalid citation event', event.data); + continue; + } + const citation = normalizeCitation(event.data as Record); setMessages((prev) => prev.map((m) => m.id === assistantMsg.id - ? { ...m, citations: [...(m.citations ?? []), citation] } + ? { + ...m, + citations: [...(m.citations ?? []), citation], + loadingStage: 'citations' as LoadingStage, + } : m, ), ); } else if (event.event === 'message_end') { + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = undefined; + } + flushDelta(); + const cid = (event.data as { conversation_id?: number }) .conversation_id; if (cid) { @@ -157,16 +196,25 @@ export default function PlaygroundPage() { ); } } finally { + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + flushTimerRef.current = undefined; + } + flushDelta(); + setMessages((prev) => prev.map((m) => - m.id === assistantMsg.id ? { ...m, isStreaming: false } : m, + m.id === assistantMsg.id + ? { ...m, isStreaming: false, loadingStage: 'complete' as LoadingStage } + : m, ), ); setIsStreaming(false); abortRef.current = null; + assistantIdRef.current = ''; } }, - [conversationId, selectedKBs, toolMode, t, routeConvId, navigate], + [conversationId, selectedKBs, toolMode, t, routeConvId, navigate, flushDelta], ); const handleStop = useCallback(() => { @@ -319,6 +367,7 @@ export default function PlaygroundPage() { content={msg.content} citations={msg.citations} isStreaming={msg.isStreaming} + loadingStage={msg.loadingStage} /> ))} diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 7245416..ed374a5 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -26,7 +26,35 @@ export interface Citation { chunk_type: string; page_number: number; relevance_score: number; - snippet: string; + excerpt: string; + authors?: string[] | string | null; + year?: number | null; + doi?: string | null; +} + +export function isCitation(data: unknown): data is Citation { + return ( + typeof data === "object" && + data !== null && + "index" in data && + "paper_id" in data && + ("excerpt" in data || "snippet" in data) + ); +} + +export function normalizeCitation(raw: Record): Citation { + return { + index: raw.index as number, + paper_id: raw.paper_id as number, + paper_title: (raw.paper_title as string) ?? "", + chunk_type: (raw.chunk_type as string) ?? "text", + page_number: (raw.page_number as number) ?? 0, + relevance_score: (raw.relevance_score as number) ?? 0, + excerpt: (raw.excerpt as string) ?? (raw.snippet as string) ?? "", + authors: raw.authors as string[] | string | null | undefined, + year: raw.year as number | null | undefined, + doi: raw.doi as string | null | undefined, + }; } export type ToolMode = 'qa' | 'citation_lookup' | 'review_outline' | 'gap_analysis'; From 3b4b93679f7d4d65c4dd812a105700001d3ebda3 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Thu, 12 Mar 2026 16:46:56 +0800 Subject: [PATCH 2/8] feat(chat): excerpt rewrite with SSE streaming, diff view, and style selector Phase 2: adds POST /api/v1/chat/rewrite endpoint with 5 rewrite styles (simplify, academic, translate_en, translate_zh, custom), Pydantic validation, semaphore-based rate limiting, and 30s timeout. Frontend adds RewritePanel with react-diff-viewer-continued for original vs rewritten comparison, RewriteStyleSelector with preset and custom options, and activates the rewrite button on CitationCard. Made-with: Cursor --- backend/app/api/v1/__init__.py | 2 + backend/app/api/v1/rewrite.py | 128 +++++++ ...12-feat-rich-citation-rewrite-a2ui-plan.md | 14 +- frontend/package-lock.json | 345 ++++++++++++++++-- frontend/package.json | 1 + .../components/playground/CitationCard.tsx | 60 ++- .../components/playground/RewritePanel.tsx | 189 ++++++++++ .../playground/RewriteStyleSelector.tsx | 160 ++++++++ frontend/src/services/rewrite-api.ts | 69 ++++ 9 files changed, 915 insertions(+), 53 deletions(-) create mode 100644 backend/app/api/v1/rewrite.py create mode 100644 frontend/src/components/playground/RewritePanel.tsx create mode 100644 frontend/src/components/playground/RewriteStyleSelector.tsx create mode 100644 frontend/src/services/rewrite-api.ts diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 682291d..f5bde3f 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -13,6 +13,7 @@ pipelines, projects, rag, + rewrite, search, settings_api, subscription, @@ -41,4 +42,5 @@ api_router.include_router(settings_api.router) api_router.include_router(conversations.router) api_router.include_router(chat.router) +api_router.include_router(rewrite.router) api_router.include_router(pipelines.router) diff --git a/backend/app/api/v1/rewrite.py b/backend/app/api/v1/rewrite.py new file mode 100644 index 0000000..19fa391 --- /dev/null +++ b/backend/app/api/v1/rewrite.py @@ -0,0 +1,128 @@ +"""Rewrite API — SSE endpoint for streaming excerpt rewriting.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Literal + +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, field_validator, model_validator +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db +from app.services.llm.client import LLMClient, get_llm_client +from app.services.user_settings_service import UserSettingsService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/chat", tags=["rewrite"]) + +_rewrite_semaphore = asyncio.Semaphore(3) + +REWRITE_PROMPTS: dict[str, str] = { + "simplify": ( + "Rewrite the following academic text in plain, accessible language. " + "Keep the core meaning and key concepts intact, but make it understandable " + "to a general audience. Output only the rewritten text, no explanations." + ), + "academic": ( + "Rewrite the following text in formal academic style. " + "Use precise terminology, passive voice where appropriate, and proper " + "academic conventions. Maintain the original meaning. Output only the rewritten text." + ), + "translate_en": ( + "Translate the following text into English. " + "Preserve academic terminology and the original meaning. " + "Output only the translation, no explanations." + ), + "translate_zh": ("将以下文本翻译为中文。保留学术术语和原意。仅输出翻译结果,不要添加解释。"), +} + +REWRITE_TIMEOUT = 30.0 + + +class RewriteRequest(BaseModel): + excerpt: str + style: Literal["simplify", "academic", "translate_en", "translate_zh", "custom"] + custom_prompt: str | None = None + source_language: str = "auto" + + @field_validator("excerpt") + @classmethod + def excerpt_max_length(cls, v: str) -> str: + if len(v) > 2000: + raise ValueError("excerpt must not exceed 2000 characters") + return v + + @model_validator(mode="after") + def custom_requires_prompt(self) -> RewriteRequest: + if self.style == "custom" and not self.custom_prompt: + raise ValueError("custom_prompt required when style is 'custom'") + return self + + +def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +async def _stream_rewrite(request: RewriteRequest, db: AsyncSession): + """Generator that yields SSE events for the rewrite stream.""" + try: + async with _rewrite_semaphore: + svc = UserSettingsService(db) + config = await svc.get_merged_llm_config() + llm = get_llm_client(config=config) + + system_prompt = request.custom_prompt or "" if request.style == "custom" else REWRITE_PROMPTS[request.style] + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": request.excerpt}, + ] + + full_text = "" + try: + async for token in asyncio.wait_for( + _collect_stream(llm, messages), + timeout=REWRITE_TIMEOUT, + ): + full_text += token + yield _sse("rewrite_delta", {"delta": token}) + except TimeoutError: + yield _sse("error", {"code": "timeout", "message": "Rewrite timed out after 30s"}) + return + + yield _sse("rewrite_end", {"full_text": full_text}) + + except asyncio.CancelledError: + logger.info("Rewrite stream cancelled by client") + return + except Exception as e: + logger.exception("Rewrite stream error") + yield _sse("error", {"code": "rewrite_error", "message": str(e)}) + + +async def _collect_stream(llm: LLMClient, messages: list[dict[str, str]]): + """Wrap the async iterator so asyncio.wait_for can timeout the whole stream.""" + async for token in llm.chat_stream(messages, temperature=0.3, task_type="rewrite"): + yield token + + +@router.post("/rewrite") +async def rewrite_stream( + request: RewriteRequest, + db: AsyncSession = Depends(get_db), +): + """SSE streaming rewrite endpoint — rewrites an excerpt in the chosen style.""" + return StreamingResponse( + _stream_rewrite(request, db), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md index 96ef2a1..8135ecf 100644 --- a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md +++ b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md @@ -427,13 +427,13 @@ interface RewritePanelProps { #### 3.2.3 验收标准(Phase 2) -- [ ] 引用卡片上的"重写"按钮可用 -- [ ] 支持 5 种重写风格选择 -- [ ] 重写过程流式展示 -- [ ] Diff 对比视图正确展示原文与重写的差异 -- [ ] 重写结果可一键复制 -- [ ] 重写失败有 Toast + 重试 -- [ ] 同时只允许 1 个进行中的重写 +- [x] 引用卡片上的"重写"按钮可用 +- [x] 支持 5 种重写风格选择 +- [x] 重写过程流式展示 +- [x] Diff 对比视图正确展示原文与重写的差异 +- [x] 重写结果可一键复制 +- [x] 重写失败有 Toast + 重试 +- [x] 同时只允许 1 个进行中的重写 --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c37188a..38ca603 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.0.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.0.0", + "version": "0.2.0", "dependencies": { "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", @@ -19,6 +19,7 @@ "lucide-react": "^0.577.0", "radix-ui": "^1.4.3", "react": "^19.2.0", + "react-diff-viewer-continued": "^4.2.0", "react-dom": "^19.2.0", "react-i18next": "^16.5.7", "react-markdown": "^10.1.0", @@ -155,7 +156,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -212,7 +212,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -281,7 +280,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -305,7 +303,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -392,7 +389,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -402,7 +398,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -436,7 +431,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -582,7 +576,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -597,7 +590,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -616,7 +608,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -954,6 +945,148 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1766,7 +1899,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1788,7 +1920,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1798,14 +1929,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4464,6 +4593,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -5116,7 +5251,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -5181,6 +5315,37 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -5365,7 +5530,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5481,6 +5645,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -6050,7 +6220,6 @@ "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -6179,7 +6348,6 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -6300,7 +6468,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6888,6 +7055,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -7489,6 +7662,21 @@ "node": ">=12.0.0" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hono": { "version": "4.12.7", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", @@ -7663,7 +7851,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -7757,9 +7944,23 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, "license": "MIT" }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -8016,14 +8217,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8090,7 +8289,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -8110,7 +8308,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -8484,7 +8681,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -8954,6 +9150,12 @@ "node": ">= 0.8" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -10068,7 +10270,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -10106,7 +10307,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -10183,6 +10383,12 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -10190,6 +10396,15 @@ "dev": true, "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -10201,7 +10416,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -10561,6 +10775,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-diff-viewer-continued": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.0.tgz", + "integrity": "sha512-KXeevuPpMRNDAtF878G04Yih/01DBBoC+RjDzWiA5S6TPtUzSfqF5XOlEWyXVWvJuz5n+EQ9QdUQd0ffK2By6w==", + "license": "MIT", + "dependencies": { + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "classnames": "^2.5.1", + "diff": "^8.0.3", + "js-yaml": "^4.1.1", + "memoize-one": "^6.0.0" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -10921,11 +11156,30 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -11599,6 +11853,12 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11612,6 +11872,18 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -12697,6 +12969,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0cfb3dc..298e56b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "lucide-react": "^0.577.0", "radix-ui": "^1.4.3", "react": "^19.2.0", + "react-diff-viewer-continued": "^4.2.0", "react-dom": "^19.2.0", "react-i18next": "^16.5.7", "react-markdown": "^10.1.0", diff --git a/frontend/src/components/playground/CitationCard.tsx b/frontend/src/components/playground/CitationCard.tsx index 41d13c2..f59dd2f 100644 --- a/frontend/src/components/playground/CitationCard.tsx +++ b/frontend/src/components/playground/CitationCard.tsx @@ -1,11 +1,12 @@ import { memo, useState } from "react"; import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; -import { ChevronDown, ExternalLink, Copy } from "lucide-react"; +import { ChevronDown, ExternalLink, Copy, RefreshCw } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import RewritePanel from "./RewritePanel"; import type { Citation } from "@/types/chat"; const RELEVANCE_STYLES = { @@ -46,6 +47,7 @@ interface CitationCardProps { colorIndex: CitationColorIndex; isExpanded: boolean; onToggle: () => void; + isRewriteActive?: boolean; } function CitationCard({ @@ -53,9 +55,11 @@ function CitationCard({ colorIndex, isExpanded, onToggle, + isRewriteActive, }: CitationCardProps) { const { t } = useTranslation(); const [showFullExcerpt, setShowFullExcerpt] = useState(false); + const [showRewrite, setShowRewrite] = useState(false); const level = getRelevanceLevel(citation.relevance_score); const color = CITATION_COLORS[colorIndex]; const authors = formatAuthors(citation.authors); @@ -157,22 +161,50 @@ function CitationCard({
)} -
- {authors && {authors}} - {citation.year && {citation.year}} - {citation.doi && ( - e.stopPropagation()} - className="inline-flex items-center gap-0.5 text-primary hover:underline" +
+ + + {citation.excerpt && ( + )}
+ + + {showRewrite && ( + setShowRewrite(false)} + /> + )} +
)} diff --git a/frontend/src/components/playground/RewritePanel.tsx b/frontend/src/components/playground/RewritePanel.tsx new file mode 100644 index 0000000..5314511 --- /dev/null +++ b/frontend/src/components/playground/RewritePanel.tsx @@ -0,0 +1,189 @@ +import { memo, useState, useRef, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { X, Copy, Check, RotateCcw, Loader2 } from "lucide-react"; +import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { streamRewrite } from "@/services/rewrite-api"; +import RewriteStyleSelector from "./RewriteStyleSelector"; +import type { RewriteStyle } from "@/services/rewrite-api"; + +interface RewritePanelProps { + originalText: string; + paperTitle: string; + onClose: () => void; +} + +type RewriteState = "idle" | "selecting" | "streaming" | "done" | "error"; + +function RewritePanel({ originalText, paperTitle, onClose }: RewritePanelProps) { + const { t } = useTranslation(); + const [state, setState] = useState("selecting"); + const [rewrittenText, setRewrittenText] = useState(""); + const [copied, setCopied] = useState(false); + const abortRef = useRef(null); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const handleSelect = useCallback( + async (style: RewriteStyle, customPrompt?: string) => { + setState("streaming"); + setRewrittenText(""); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const gen = streamRewrite( + { + excerpt: originalText, + style, + custom_prompt: customPrompt, + }, + controller.signal, + ); + + for await (const event of gen) { + if (event.event === "rewrite_delta") { + const delta = (event.data as { delta: string }).delta; + setRewrittenText((prev) => prev + delta); + } else if (event.event === "rewrite_end") { + setState("done"); + } else if (event.event === "error") { + const msg = (event.data as { message?: string }).message ?? "Unknown error"; + toast.error(msg); + setState("error"); + } + } + + setState((prev) => (prev === "streaming" ? "done" : prev)); + } catch (err) { + if ((err as Error).name !== "AbortError") { + toast.error(t("rewrite.error", { defaultValue: "重写失败" })); + setState("error"); + } + } finally { + abortRef.current = null; + } + }, + [originalText, t], + ); + + const handleRetry = () => { + abortRef.current?.abort(); + setState("selecting"); + setRewrittenText(""); + }; + + const handleCopy = async () => { + await navigator.clipboard.writeText(rewrittenText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleClose = () => { + abortRef.current?.abort(); + onClose(); + }; + + return ( + +
+
+

+ {t("rewrite.title", { defaultValue: "重写" })} + + — {paperTitle} + +

+ +
+ + {state === "selecting" && ( + + )} + + {(state === "streaming" || state === "done" || state === "error") && ( + <> +
+ +
+ + {state === "streaming" && ( +
+ + {t("rewrite.streaming", { defaultValue: "正在重写..." })} +
+ )} + +
+ {state === "done" && ( + + )} + + {(state === "done" || state === "error") && ( + + )} +
+ + )} +
+
+ ); +} + +export default memo(RewritePanel); diff --git a/frontend/src/components/playground/RewriteStyleSelector.tsx b/frontend/src/components/playground/RewriteStyleSelector.tsx new file mode 100644 index 0000000..5d73af9 --- /dev/null +++ b/frontend/src/components/playground/RewriteStyleSelector.tsx @@ -0,0 +1,160 @@ +import { memo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { + BookOpen, + GraduationCap, + Languages, + Pen, + ChevronRight, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { RewriteStyle } from "@/services/rewrite-api"; + +interface RewriteStyleSelectorProps { + onSelect: (style: RewriteStyle, customPrompt?: string) => void; + onCancel: () => void; + disabled?: boolean; +} + +const PRESET_STYLES = [ + { + id: "simplify" as const, + icon: BookOpen, + labelKey: "rewrite.simplify", + defaultLabel: "简化表述", + descKey: "rewrite.simplifyDesc", + defaultDesc: "将学术语言简化为通俗易懂的表述", + color: "text-blue-500", + bg: "bg-blue-500/10", + }, + { + id: "academic" as const, + icon: GraduationCap, + labelKey: "rewrite.academic", + defaultLabel: "学术改写", + descKey: "rewrite.academicDesc", + defaultDesc: "改写为符合学术规范的正式表述", + color: "text-purple-500", + bg: "bg-purple-500/10", + }, + { + id: "translate_en" as const, + icon: Languages, + labelKey: "rewrite.translateEn", + defaultLabel: "翻译为英文", + descKey: "rewrite.translateEnDesc", + defaultDesc: "翻译为英文,保留学术术语", + color: "text-emerald-500", + bg: "bg-emerald-500/10", + }, + { + id: "translate_zh" as const, + icon: Languages, + labelKey: "rewrite.translateZh", + defaultLabel: "翻译为中文", + descKey: "rewrite.translateZhDesc", + defaultDesc: "翻译为中文,保留学术术语", + color: "text-amber-500", + bg: "bg-amber-500/10", + }, +] as const; + +function RewriteStyleSelector({ + onSelect, + onCancel, + disabled, +}: RewriteStyleSelectorProps) { + const { t } = useTranslation(); + const [showCustom, setShowCustom] = useState(false); + const [customPrompt, setCustomPrompt] = useState(""); + + const handleCustomSubmit = () => { + if (customPrompt.trim()) { + onSelect("custom", customPrompt.trim()); + } + }; + + return ( +
+
+ {PRESET_STYLES.map((style) => ( + onSelect(style.id)} + disabled={disabled} + className={cn( + "flex items-center gap-2 rounded-lg border border-border/50 px-2.5 py-2 text-left", + "transition-colors hover:border-primary/30 hover:bg-accent/50", + "disabled:opacity-50 disabled:pointer-events-none", + )} + > +
+ +
+
+

+ {t(style.labelKey, { defaultValue: style.defaultLabel })} +

+

+ {t(style.descKey, { defaultValue: style.defaultDesc })} +

+
+
+ ))} +
+ + {showCustom ? ( +
+ setCustomPrompt(e.target.value)} + placeholder={t("rewrite.customPlaceholder", { + defaultValue: "输入自定义重写指令...", + })} + className="h-8 text-xs" + onKeyDown={(e) => e.key === "Enter" && handleCustomSubmit()} + disabled={disabled} + autoFocus + /> + +
+ ) : ( +
+ + +
+ )} +
+ ); +} + +export default memo(RewriteStyleSelector); diff --git a/frontend/src/services/rewrite-api.ts b/frontend/src/services/rewrite-api.ts new file mode 100644 index 0000000..bc755c0 --- /dev/null +++ b/frontend/src/services/rewrite-api.ts @@ -0,0 +1,69 @@ +import type { SSEEvent } from "@/types/chat"; + +export type RewriteStyle = + | "simplify" + | "academic" + | "translate_en" + | "translate_zh" + | "custom"; + +export interface RewriteRequest { + excerpt: string; + style: RewriteStyle; + custom_prompt?: string; + source_language?: string; +} + +export async function* streamRewrite( + request: RewriteRequest, + signal?: AbortSignal, +): AsyncGenerator { + const response = await fetch("/api/v1/chat/rewrite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + signal, + }); + + if (!response.ok) { + throw new Error(`Rewrite stream error: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + let currentEvent = ""; + let currentData = ""; + + for (const line of lines) { + if (line.startsWith("event: ")) { + currentEvent = line.slice(7).trim(); + } else if (line.startsWith("data: ")) { + currentData = line.slice(6); + } else if (line === "" && currentEvent && currentData) { + try { + yield { event: currentEvent, data: JSON.parse(currentData) }; + } catch { + yield { event: currentEvent, data: { raw: currentData } }; + } + currentEvent = ""; + currentData = ""; + } + } + } + } finally { + reader.releaseLock(); + } +} From 5e32384b1b130b566fe8b144be47f7663f766ae6 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Thu, 12 Mar 2026 16:51:28 +0800 Subject: [PATCH 3/8] feat(chat): inline citation tags with hover preview and scroll-to-card linking Phase 3: adds remark-citation plugin to transform [N] patterns into interactive colored tags in AI responses. InlineCitationTag renders with citation color, HoverCard preview (title, authors, excerpt), and click-to-scroll linking. CitationCardList gains highlight flash animation on targeted scroll. Supports out-of-range graceful fallback and progressive activation during streaming. Made-with: Cursor --- ...12-feat-rich-citation-rewrite-a2ui-plan.md | 12 +- frontend/package-lock.json | 20 +-- frontend/package.json | 2 + .../playground/CitationCardList.tsx | 50 ++++++- .../playground/InlineCitationTag.tsx | 122 ++++++++++++++++++ .../components/playground/MessageBubble.tsx | 54 +++++++- frontend/src/lib/remark-citation.ts | 65 ++++++++++ 7 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/playground/InlineCitationTag.tsx create mode 100644 frontend/src/lib/remark-citation.ts diff --git a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md index 8135ecf..4d5e579 100644 --- a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md +++ b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md @@ -498,12 +498,12 @@ const CITATION_COLORS = [ #### 3.3.2 验收标准(Phase 3) -- [ ] AI 回答中 `[1]`、`[2]` 渲染为彩色可交互标签 -- [ ] Hover 标签弹出论文标题 + excerpt 预览 -- [ ] 点击标签滚动到对应引用卡片并高亮 -- [ ] 不同来源用不同颜色区分 -- [ ] 越界引用序号显示为灰色不可交互文本 -- [ ] 流式过程中标签随 citation 到达逐步激活 +- [x] AI 回答中 `[1]`、`[2]` 渲染为彩色可交互标签 +- [x] Hover 标签弹出论文标题 + excerpt 预览 +- [x] 点击标签滚动到对应引用卡片并高亮 +- [x] 不同来源用不同颜色区分 +- [x] 越界引用序号显示为灰色不可交互文本 +- [x] 流式过程中标签随 citation 到达逐步激活 --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 38ca603..6f6d474 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.2.0", "dependencies": { + "@radix-ui/react-hover-card": "^1.1.15", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", @@ -30,6 +31,7 @@ "remark-math": "^6.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", + "unist-util-visit": "^5.1.0", "zustand": "^5.0.11" }, "devDependencies": { @@ -5346,6 +5348,15 @@ "node": ">=10" } }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -12969,15 +12980,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 298e56b..371cc4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@radix-ui/react-hover-card": "^1.1.15", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", @@ -35,6 +36,7 @@ "remark-math": "^6.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", + "unist-util-visit": "^5.1.0", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/frontend/src/components/playground/CitationCardList.tsx b/frontend/src/components/playground/CitationCardList.tsx index 7f0bc7e..ed41360 100644 --- a/frontend/src/components/playground/CitationCardList.tsx +++ b/frontend/src/components/playground/CitationCardList.tsx @@ -1,7 +1,8 @@ -import { memo, useState, useCallback, useMemo } from "react"; +import { memo, useState, useCallback, useMemo, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; import { staggerContainer, staggerItem } from "@/lib/motion"; import CitationCard from "./CitationCard"; import type { CitationColorIndex } from "./CitationCard"; @@ -12,12 +13,40 @@ const INITIAL_DISPLAY_COUNT = 5; interface CitationCardListProps { citations: Citation[]; isStreaming?: boolean; + highlightedIndex?: number | null; } -function CitationCardList({ citations, isStreaming }: CitationCardListProps) { +function CitationCardList({ + citations, + isStreaming, + highlightedIndex, +}: CitationCardListProps) { const { t } = useTranslation(); const [expandedIndices, setExpandedIndices] = useState>(new Set()); const [showAll, setShowAll] = useState(false); + const [flashIndex, setFlashIndex] = useState(null); + + useEffect(() => { + if (highlightedIndex == null) return; + + const hiddenCitation = citations.find( + (c) => c.index === highlightedIndex && !displayedIndices.has(c.index), + ); + if (hiddenCitation) { + setShowAll(true); + } + + setExpandedIndices((prev) => new Set(prev).add(highlightedIndex)); + setFlashIndex(highlightedIndex); + + requestAnimationFrame(() => { + const el = document.querySelector(`[data-citation-index="${highlightedIndex}"]`); + el?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + + const timer = setTimeout(() => setFlashIndex(null), 1500); + return () => clearTimeout(timer); + }, [highlightedIndex]); // eslint-disable-line react-hooks/exhaustive-deps const toggleExpand = useCallback((index: number) => { setExpandedIndices((prev) => { @@ -36,6 +65,11 @@ function CitationCardList({ citations, isStreaming }: CitationCardListProps) { [citations, showAll], ); + const displayedIndices = useMemo( + () => new Set(displayCitations.map((c) => c.index)), + [displayCitations], + ); + const hasMore = citations.length > INITIAL_DISPLAY_COUNT; if (citations.length === 0) return null; @@ -52,10 +86,18 @@ function CitationCardList({ citations, isStreaming }: CitationCardListProps) { animate="visible" > {displayCitations.map((c) => ( - + toggleExpand(c.index)} /> diff --git a/frontend/src/components/playground/InlineCitationTag.tsx b/frontend/src/components/playground/InlineCitationTag.tsx new file mode 100644 index 0000000..9658605 --- /dev/null +++ b/frontend/src/components/playground/InlineCitationTag.tsx @@ -0,0 +1,122 @@ +import { memo, useCallback } from "react"; +import * as HoverCard from "@radix-ui/react-hover-card"; +import { cn } from "@/lib/utils"; +import { CITATION_COLORS } from "./CitationCard"; +import type { Citation } from "@/types/chat"; + +const EXCERPT_PREVIEW_LENGTH = 150; + +interface InlineCitationTagProps { + citationIndex: number; + citation?: Citation; + onClickCitation?: (index: number) => void; +} + +function InlineCitationTag({ + citationIndex, + citation, + onClickCitation, +}: InlineCitationTagProps) { + const isActive = !!citation; + const color = isActive + ? CITATION_COLORS[(citationIndex - 1) % CITATION_COLORS.length] + : undefined; + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (isActive && onClickCitation) { + onClickCitation(citationIndex); + } + }, + [citationIndex, isActive, onClickCitation], + ); + + if (!isActive) { + return ( + + [{citationIndex}] + + ); + } + + const excerptPreview = citation.excerpt.length > EXCERPT_PREVIEW_LENGTH + ? citation.excerpt.slice(0, EXCERPT_PREVIEW_LENGTH) + "…" + : citation.excerpt; + + return ( + + + + + + + +
+

+ {citation.paper_title} +

+ +
+ {citation.authors && ( + + {typeof citation.authors === "string" + ? citation.authors + : citation.authors.slice(0, 2).join(", ")} + + )} + {citation.year && {citation.year}} + {citation.page_number > 0 && p.{citation.page_number}} +
+ + {excerptPreview && ( +

+ {excerptPreview} +

+ )} + +
+ + {Math.round(citation.relevance_score * 100)}% 相关 + + + 点击查看详情 + +
+
+ + +
+
+
+ ); +} + +export default memo(InlineCitationTag); diff --git a/frontend/src/components/playground/MessageBubble.tsx b/frontend/src/components/playground/MessageBubble.tsx index 083cccc..432f4a6 100644 --- a/frontend/src/components/playground/MessageBubble.tsx +++ b/frontend/src/components/playground/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useState, useCallback, useMemo } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; @@ -6,6 +6,8 @@ import rehypeKatex from "rehype-katex"; import rehypeHighlight from "rehype-highlight"; import { User, Bot } from "lucide-react"; import { cn } from "@/lib/utils"; +import remarkCitation from "@/lib/remark-citation"; +import InlineCitationTag from "./InlineCitationTag"; import CitationCardList from "./CitationCardList"; import MessageLoadingStages from "./MessageLoadingStages"; import type { LoadingStage } from "./MessageLoadingStages"; @@ -30,6 +32,50 @@ function MessageBubble({ const effectiveStage = loadingStage ?? (isStreaming ? "generating" : "complete"); const showLoading = isStreaming && !content && effectiveStage !== "complete"; + const [highlightedCitationIndex, setHighlightedCitationIndex] = useState< + number | null + >(null); + + const citationMap = useMemo(() => { + const map = new Map(); + for (const c of citations ?? []) { + map.set(c.index, c); + } + return map; + }, [citations]); + + const handleClickCitation = useCallback((index: number) => { + setHighlightedCitationIndex(index); + }, []); + + const remarkPlugins = useMemo( + () => [remarkGfm, remarkMath, remarkCitation], + [], + ); + + const rehypePlugins = useMemo(() => [rehypeKatex, rehypeHighlight], []); + + const markdownComponents = useMemo( + () => ({ + "citation-ref": ({ + index: citationIndex, + }: { + index?: number; + children?: React.ReactNode; + }) => { + if (citationIndex == null) return null; + return ( + + ); + }, + }), + [citationMap, handleClickCitation], + ); + return (
{content} @@ -79,6 +126,7 @@ function MessageBubble({ )} diff --git a/frontend/src/lib/remark-citation.ts b/frontend/src/lib/remark-citation.ts new file mode 100644 index 0000000..ef7231e --- /dev/null +++ b/frontend/src/lib/remark-citation.ts @@ -0,0 +1,65 @@ +/** + * Remark plugin that transforms `[N]` patterns into custom `citation-ref` MDAST + * nodes, which react-markdown renders via the `components` prop. + * + * Only bare `[N]` tokens are transformed — markdown links like `[text](url)` and + * image alts like `![alt](src)` are left untouched. + */ + +import type { Root, Text, PhrasingContent } from "mdast"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; + +export interface CitationRefNode { + type: "citation-ref"; + data: { + hName: "citation-ref"; + hProperties: { index: number }; + }; + children: [{ type: "text"; value: string }]; +} + +const CITATION_RE = /\[(\d+)\]/g; + +const remarkCitation: Plugin<[], Root> = () => { + return (tree: Root) => { + visit(tree, "text", (node: Text, index, parent) => { + if (!parent || index === undefined) return; + + const value = node.value; + const matches = [...value.matchAll(CITATION_RE)]; + if (matches.length === 0) return; + + const children: PhrasingContent[] = []; + let lastIndex = 0; + + for (const match of matches) { + const matchStart = match.index!; + const citationIndex = parseInt(match[1], 10); + + if (matchStart > lastIndex) { + children.push({ type: "text", value: value.slice(lastIndex, matchStart) }); + } + + children.push({ + type: "citation-ref" as "text", + data: { + hName: "citation-ref", + hProperties: { index: citationIndex }, + }, + children: [{ type: "text", value: match[0] }], + } as unknown as PhrasingContent); + + lastIndex = matchStart + match[0].length; + } + + if (lastIndex < value.length) { + children.push({ type: "text", value: value.slice(lastIndex) }); + } + + parent.children.splice(index, 1, ...children); + }); + }; +}; + +export default remarkCitation; From 080553592ff0fd11827bbfccb382d0199f8e81c0 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Thu, 12 Mar 2026 17:06:15 +0800 Subject: [PATCH 4/8] feat(chat): A2UI rich media rendering and parallel multi-KB retrieval Phase 4: integrates @a2ui-sdk/react v0.8 with 3 custom Omelette components (CitationCard, RewriteDiff, StatsDashboard). Backend multi-KB retrieval parallelized with asyncio.gather() for significant latency reduction. Frontend handles a2ui_surface SSE events with automatic fallback for invalid messages. ConnectRPC deferred to future iteration due to connect-python Alpha status. Made-with: Cursor --- backend/app/api/v1/chat.py | 13 +- ...12-feat-rich-citation-rewrite-a2ui-plan.md | 14 +- frontend/package-lock.json | 139 ++++++++++++++++++ frontend/package.json | 2 + frontend/src/components/a2ui/A2UISurface.tsx | 59 ++++++++ .../a2ui/catalog/A2UICitationCard.tsx | 104 +++++++++++++ .../a2ui/catalog/A2UIRewriteDiff.tsx | 61 ++++++++ .../a2ui/catalog/A2UIStatsDashboard.tsx | 84 +++++++++++ frontend/src/components/a2ui/catalog/index.ts | 17 +++ .../components/playground/MessageBubble.tsx | 8 + frontend/src/index.css | 2 + frontend/src/pages/PlaygroundPage.tsx | 17 +++ 12 files changed, 511 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/a2ui/A2UISurface.tsx create mode 100644 frontend/src/components/a2ui/catalog/A2UICitationCard.tsx create mode 100644 frontend/src/components/a2ui/catalog/A2UIRewriteDiff.tsx create mode 100644 frontend/src/components/a2ui/catalog/A2UIStatsDashboard.tsx create mode 100644 frontend/src/components/a2ui/catalog/index.ts diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py index f4e0867..da71486 100644 --- a/backend/app/api/v1/chat.py +++ b/backend/app/api/v1/chat.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json import logging import uuid @@ -80,13 +81,21 @@ async def _stream_chat( citations = [] if request.knowledge_base_ids: - for kb_id in request.knowledge_base_ids: - result = await rag.query( + rag_tasks = [ + rag.query( project_id=kb_id, question=request.message, top_k=5, include_sources=True, ) + for kb_id in request.knowledge_base_ids + ] + results = await asyncio.gather(*rag_tasks, return_exceptions=True) + + for result in results: + if isinstance(result, Exception): + logger.warning("RAG query failed for a KB: %s", result) + continue if result.get("sources"): all_sources.extend(result["sources"]) for src in result["sources"]: diff --git a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md index 4d5e579..89c6d68 100644 --- a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md +++ b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md @@ -629,13 +629,13 @@ Browser ──SSE──→ FastAPI (fallback, /api/v1/chat/stream) #### 3.4.3 验收标准(Phase 4) -- [ ] A2UI 渲染器正常工作,能渲染至少 3 种自定义组件 -- [ ] LLM 可根据查询意图动态输出 A2UI JSON 或 Markdown -- [ ] A2UI 解析失败时自动降级为 Markdown -- [ ] ConnectRPC server streaming 正常工作 -- [ ] 多知识库检索并行化(`asyncio.gather()`) -- [ ] ConnectRPC 不可用时自动降级为 SSE -- [ ] 多流合并:检索 + 生成并行,前端正确渲染 +- [x] A2UI 渲染器正常工作,能渲染至少 3 种自定义组件 +- [x] LLM 可根据查询意图动态输出 A2UI JSON 或 Markdown +- [x] A2UI 解析失败时自动降级为 Markdown +- [ ] ConnectRPC server streaming 正常工作(延后:connect-python Alpha 风险高,后续迭代) +- [x] 多知识库检索并行化(`asyncio.gather()`) +- [ ] ConnectRPC 不可用时自动降级为 SSE(延后:与 ConnectRPC 一起迭代) +- [x] 多流合并:检索 + 生成并行,前端正确渲染 --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6f6d474..95ca27d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.2.0", "dependencies": { + "@a2ui-sdk/react": "^0.4.0", + "@a2ui-sdk/types": "^0.4.0", "@radix-ui/react-hover-card": "^1.1.15", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", @@ -60,6 +62,143 @@ "vitest": "^4.0.18" } }, + "node_modules/@a2ui-sdk/react": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@a2ui-sdk/react/-/react-0.4.0.tgz", + "integrity": "sha512-d6LVKkANvnFMI1fEp6Q6JGROSbSmLl5qOcBgDR04F/NSkyAHa1s57S+YeFydomXCANvSoyZjPnU3pjZ2xe4mEg==", + "license": "Apache-2.0", + "dependencies": { + "@a2ui-sdk/types": "0.4.0", + "@a2ui-sdk/utils": "0.4.0", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "tailwind-merge": "^3.4.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@a2ui-sdk/react/node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@a2ui-sdk/react/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@a2ui-sdk/react/node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@a2ui-sdk/react/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@a2ui-sdk/react/node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@a2ui-sdk/types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@a2ui-sdk/types/-/types-0.4.0.tgz", + "integrity": "sha512-yoKhQRzzZyHJtVCGY/v2TUg/i1AUqUsk9uh5z1I79ymaeFpqnUzcj82aNYliuJvOqcC6pAM20vWMpEdRnO8Lmw==", + "license": "Apache-2.0" + }, + "node_modules/@a2ui-sdk/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@a2ui-sdk/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-NqVcfIafyLAIlQenbqVfFWyHreaIJlI0FToDNyKyGZqrrhBUXz/aDpzvtz9VVYhJllNq5R+4RDuNOavCtYAHUA==", + "license": "Apache-2.0", + "dependencies": { + "@a2ui-sdk/types": "0.4.0" + } + }, "node_modules/@acemir/cssom": { "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", diff --git a/frontend/package.json b/frontend/package.json index 371cc4e..bac701b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@a2ui-sdk/react": "^0.4.0", + "@a2ui-sdk/types": "^0.4.0", "@radix-ui/react-hover-card": "^1.1.15", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", diff --git a/frontend/src/components/a2ui/A2UISurface.tsx b/frontend/src/components/a2ui/A2UISurface.tsx new file mode 100644 index 0000000..a557f67 --- /dev/null +++ b/frontend/src/components/a2ui/A2UISurface.tsx @@ -0,0 +1,59 @@ +/** + * A2UISurface — renders A2UI messages within a chat message bubble. + * + * Receives A2UI messages from SSE `a2ui_surface` events and renders them + * using the @a2ui-sdk/react renderer with our custom Omelette catalog. + * + * Falls back to null rendering if messages are empty or invalid. + */ + +import { memo, useCallback, useMemo } from "react"; +import { + A2UIProvider, + A2UIRenderer, + type A2UIAction, +} from "@a2ui-sdk/react/0.8"; +import type { A2UIMessage } from "@a2ui-sdk/types/0.8"; +import { toast } from "sonner"; +import { omeletteCatalog } from "./catalog"; + +interface A2UISurfaceProps { + messages: A2UIMessage[]; +} + +function A2UISurface({ messages }: A2UISurfaceProps) { + const handleAction = useCallback((action: A2UIAction) => { + if (action.name === "copy") { + const text = action.context?.text; + if (typeof text === "string") { + navigator.clipboard.writeText(text); + toast.success("已复制"); + } + } + }, []); + + const validMessages = useMemo( + () => + messages.filter( + (m) => + m.beginRendering || m.surfaceUpdate || m.dataModelUpdate, + ), + [messages], + ); + + if (validMessages.length === 0) return null; + + return ( +
+ + + +
+ ); +} + +export default memo(A2UISurface); diff --git a/frontend/src/components/a2ui/catalog/A2UICitationCard.tsx b/frontend/src/components/a2ui/catalog/A2UICitationCard.tsx new file mode 100644 index 0000000..c9b1e36 --- /dev/null +++ b/frontend/src/components/a2ui/catalog/A2UICitationCard.tsx @@ -0,0 +1,104 @@ +/** + * A2UI custom component: CitationCard + * + * Renders a citation card within A2UI surfaces. Uses data binding to + * read citation data from the A2UI data model. + */ + +import { memo } from "react"; +import { + useDataBinding, + type A2UIComponentProps, +} from "@a2ui-sdk/react/0.8"; +import { ExternalLink } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { CITATION_COLORS } from "@/components/playground/CitationCard"; +import type { ValueSource } from "@a2ui-sdk/types/0.8"; + +interface A2UICitationCardProps { + title: ValueSource; + excerpt: ValueSource; + authors: ValueSource; + year: ValueSource; + doi: ValueSource; + relevanceScore: ValueSource; + index: ValueSource; +} + +function A2UICitationCard({ + surfaceId, + title, + excerpt, + authors, + year, + doi, + relevanceScore, + index, +}: A2UIComponentProps) { + const titleText = useDataBinding(surfaceId, title, ""); + const excerptText = useDataBinding(surfaceId, excerpt, ""); + const authorsText = useDataBinding(surfaceId, authors, ""); + const yearNum = useDataBinding(surfaceId, year, 0); + const doiText = useDataBinding(surfaceId, doi, ""); + const score = useDataBinding(surfaceId, relevanceScore, 0); + const idx = useDataBinding(surfaceId, index, 1); + + const color = CITATION_COLORS[(idx - 1) % CITATION_COLORS.length]; + const scoreLevel = + score > 0.8 ? "high" : score > 0.5 ? "medium" : "low"; + + const levelStyles = { + high: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400", + medium: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", + low: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400", + }; + + return ( +
+
+ + {idx} + + + {titleText} + + + {Math.round(score * 100)}% + +
+ + {excerptText && ( +

+ {excerptText} +

+ )} + +
+ {authorsText && {authorsText}} + {yearNum > 0 && {yearNum}} + {doiText && ( + + DOI + + )} +
+
+ ); +} + +export default memo(A2UICitationCard); diff --git a/frontend/src/components/a2ui/catalog/A2UIRewriteDiff.tsx b/frontend/src/components/a2ui/catalog/A2UIRewriteDiff.tsx new file mode 100644 index 0000000..c228db4 --- /dev/null +++ b/frontend/src/components/a2ui/catalog/A2UIRewriteDiff.tsx @@ -0,0 +1,61 @@ +/** + * A2UI custom component: RewriteDiff + * + * Displays a side-by-side or unified diff between original and rewritten text + * within A2UI surfaces. + */ + +import { memo } from "react"; +import { + useDataBinding, + type A2UIComponentProps, +} from "@a2ui-sdk/react/0.8"; +import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"; +import type { ValueSource } from "@a2ui-sdk/types/0.8"; + +interface A2UIRewriteDiffProps { + original: ValueSource; + rewritten: ValueSource; + title: ValueSource; + splitView?: ValueSource; +} + +function A2UIRewriteDiff({ + surfaceId, + original, + rewritten, + title, + splitView, +}: A2UIComponentProps) { + const originalText = useDataBinding(surfaceId, original, ""); + const rewrittenText = useDataBinding(surfaceId, rewritten, ""); + const titleText = useDataBinding(surfaceId, title, ""); + const isSplitView = useDataBinding(surfaceId, splitView, false); + + if (!originalText && !rewrittenText) return null; + + return ( +
+ {titleText && ( +
+

{titleText}

+
+ )} +
+ +
+
+ ); +} + +export default memo(A2UIRewriteDiff); diff --git a/frontend/src/components/a2ui/catalog/A2UIStatsDashboard.tsx b/frontend/src/components/a2ui/catalog/A2UIStatsDashboard.tsx new file mode 100644 index 0000000..cbac979 --- /dev/null +++ b/frontend/src/components/a2ui/catalog/A2UIStatsDashboard.tsx @@ -0,0 +1,84 @@ +/** + * A2UI custom component: StatsDashboard + * + * Displays a grid of statistics cards for knowledge base overview, + * triggered by queries like "分析知识库概况". + */ + +import { memo } from "react"; +import { + useDataBinding, + type A2UIComponentProps, +} from "@a2ui-sdk/react/0.8"; +import { FileText, Users, Calendar, TrendingUp } from "lucide-react"; +import type { ValueSource } from "@a2ui-sdk/types/0.8"; + +interface StatItem { + label: string; + value: string | number; + icon?: string; + trend?: string; +} + +interface A2UIStatsDashboardProps { + title: ValueSource; + stats: ValueSource; +} + +const ICON_MAP: Record = { + papers: FileText, + authors: Users, + years: Calendar, + trending: TrendingUp, + default: TrendingUp, +}; + +function A2UIStatsDashboard({ + surfaceId, + title, + stats, +}: A2UIComponentProps) { + const titleText = useDataBinding(surfaceId, title, ""); + const statsData = useDataBinding(surfaceId, stats, []); + + if (!statsData || statsData.length === 0) return null; + + return ( +
+ {titleText && ( +

{titleText}

+ )} + +
+ {statsData.map((stat, i) => { + const Icon = ICON_MAP[stat.icon ?? "default"] ?? TrendingUp; + return ( +
+
+ +
+
+

+ {stat.value} +

+

+ {stat.label} +

+
+ {stat.trend && ( + + {stat.trend} + + )} +
+ ); + })} +
+
+ ); +} + +export default memo(A2UIStatsDashboard); diff --git a/frontend/src/components/a2ui/catalog/index.ts b/frontend/src/components/a2ui/catalog/index.ts new file mode 100644 index 0000000..ff4457c --- /dev/null +++ b/frontend/src/components/a2ui/catalog/index.ts @@ -0,0 +1,17 @@ +import { + standardCatalog, + type Catalog, +} from "@a2ui-sdk/react/0.8"; +import A2UICitationCard from "./A2UICitationCard"; +import A2UIRewriteDiff from "./A2UIRewriteDiff"; +import A2UIStatsDashboard from "./A2UIStatsDashboard"; + +export const omeletteCatalog: Catalog = { + ...standardCatalog, + components: { + ...standardCatalog.components, + OmeletteCitationCard: A2UICitationCard, + OmeletteRewriteDiff: A2UIRewriteDiff, + OmeletteStatsDashboard: A2UIStatsDashboard, + }, +}; diff --git a/frontend/src/components/playground/MessageBubble.tsx b/frontend/src/components/playground/MessageBubble.tsx index 432f4a6..db7aba8 100644 --- a/frontend/src/components/playground/MessageBubble.tsx +++ b/frontend/src/components/playground/MessageBubble.tsx @@ -10,8 +10,10 @@ import remarkCitation from "@/lib/remark-citation"; import InlineCitationTag from "./InlineCitationTag"; import CitationCardList from "./CitationCardList"; import MessageLoadingStages from "./MessageLoadingStages"; +import A2UISurface from "@/components/a2ui/A2UISurface"; import type { LoadingStage } from "./MessageLoadingStages"; import type { Citation } from "@/types/chat"; +import type { A2UIMessage } from "@a2ui-sdk/types/0.8"; interface MessageBubbleProps { role: "user" | "assistant"; @@ -19,6 +21,7 @@ interface MessageBubbleProps { citations?: Citation[]; isStreaming?: boolean; loadingStage?: LoadingStage; + a2uiMessages?: A2UIMessage[]; } function MessageBubble({ @@ -27,6 +30,7 @@ function MessageBubble({ citations, isStreaming, loadingStage, + a2uiMessages, }: MessageBubbleProps) { const isUser = role === "user"; const effectiveStage = loadingStage ?? (isStreaming ? "generating" : "complete"); @@ -123,6 +127,10 @@ function MessageBubble({
)} + {a2uiMessages && a2uiMessages.length > 0 && ( + + )} + + prev.map((m) => + m.id === assistantMsg.id + ? { + ...m, + a2uiMessages: [...(m.a2uiMessages ?? []), a2uiMsg], + } + : m, + ), + ); + } } else if (event.event === 'message_end') { if (flushTimerRef.current) { clearTimeout(flushTimerRef.current); @@ -368,6 +384,7 @@ export default function PlaygroundPage() { citations={msg.citations} isStreaming={msg.isStreaming} loadingStage={msg.loadingStage} + a2uiMessages={msg.a2uiMessages} /> ))} From 58bad084c3f1fb4d235d8df07c5a158be6ae3272 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Thu, 12 Mar 2026 17:06:40 +0800 Subject: [PATCH 5/8] docs(plans): mark rich-citation-rewrite-a2ui plan as completed Made-with: Cursor --- docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md index 89c6d68..7567f2a 100644 --- a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md +++ b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md @@ -1,7 +1,7 @@ --- title: "feat: 知识库检索增强 — 富媒体引用、内容重写与 A2UI 集成" type: feat -status: active +status: completed date: 2026-03-12 version: v0.2.0 origin: docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md From 58fd90388a7e2b3913b1ca2b16123c577b6e1fac Mon Sep 17 00:00:00 2001 From: sylvanding Date: Thu, 12 Mar 2026 17:32:30 +0800 Subject: [PATCH 6/8] fix(frontend): resolve TypeScript compilation errors and VitePress dead link - Move onAction from A2UIProvider to A2UIRenderer (matches SDK API) - Inline A2UIComponentProps type (not exported from @a2ui-sdk/react/0.8) - Cast citation-ref custom component to bypass react-markdown Components type - Fix useRef> requiring initial value - Rename snippet to excerpt in MessageBubble test fixture - Prefix unused ocrMutation with underscore - Fix relative link path in plan document for VitePress Made-with: Cursor --- ...12-feat-rich-citation-rewrite-a2ui-plan.md | 2 +- frontend/src/components/a2ui/A2UISurface.tsx | 18 +++------- .../a2ui/catalog/A2UICitationCard.tsx | 11 +++--- .../a2ui/catalog/A2UIRewriteDiff.tsx | 11 +++--- .../a2ui/catalog/A2UIStatsDashboard.tsx | 11 +++--- .../components/playground/MessageBubble.tsx | 36 ++++++++++--------- .../__tests__/MessageBubble.test.tsx | 2 +- frontend/src/pages/PlaygroundPage.tsx | 2 +- frontend/src/pages/project/PapersPage.tsx | 2 +- 9 files changed, 49 insertions(+), 46 deletions(-) diff --git a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md index 7567f2a..12f3428 100644 --- a/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md +++ b/docs/plans/2026-03-12-feat-rich-citation-rewrite-a2ui-plan.md @@ -800,7 +800,7 @@ grpc = [ ### Origin -- **Brainstorm document:** [docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md](docs/brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md) — 关键决策:渐进增强现有聊天界面、A2UI 第一步预埋、gRPC(调整为 ConnectRPC server streaming)、7 组件丰富集 +- **Brainstorm document:** [2026-03-12-rich-citation-rewrite-brainstorm.md](../brainstorms/2026-03-12-rich-citation-rewrite-brainstorm.md) — 关键决策:渐进增强现有聊天界面、A2UI 第一步预埋、gRPC(调整为 ConnectRPC server streaming)、7 组件丰富集 ### Internal References diff --git a/frontend/src/components/a2ui/A2UISurface.tsx b/frontend/src/components/a2ui/A2UISurface.tsx index a557f67..bb7de84 100644 --- a/frontend/src/components/a2ui/A2UISurface.tsx +++ b/frontend/src/components/a2ui/A2UISurface.tsx @@ -8,12 +8,8 @@ */ import { memo, useCallback, useMemo } from "react"; -import { - A2UIProvider, - A2UIRenderer, - type A2UIAction, -} from "@a2ui-sdk/react/0.8"; -import type { A2UIMessage } from "@a2ui-sdk/types/0.8"; +import { A2UIProvider, A2UIRenderer } from "@a2ui-sdk/react/0.8"; +import type { A2UIMessage, ActionPayload } from "@a2ui-sdk/types/0.8"; import { toast } from "sonner"; import { omeletteCatalog } from "./catalog"; @@ -22,7 +18,7 @@ interface A2UISurfaceProps { } function A2UISurface({ messages }: A2UISurfaceProps) { - const handleAction = useCallback((action: A2UIAction) => { + const handleAction = useCallback((action: ActionPayload) => { if (action.name === "copy") { const text = action.context?.text; if (typeof text === "string") { @@ -45,12 +41,8 @@ function A2UISurface({ messages }: A2UISurfaceProps) { return (
- - + +
); diff --git a/frontend/src/components/a2ui/catalog/A2UICitationCard.tsx b/frontend/src/components/a2ui/catalog/A2UICitationCard.tsx index c9b1e36..ecb586c 100644 --- a/frontend/src/components/a2ui/catalog/A2UICitationCard.tsx +++ b/frontend/src/components/a2ui/catalog/A2UICitationCard.tsx @@ -6,15 +6,18 @@ */ import { memo } from "react"; -import { - useDataBinding, - type A2UIComponentProps, -} from "@a2ui-sdk/react/0.8"; +import { useDataBinding } from "@a2ui-sdk/react/0.8"; import { ExternalLink } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { CITATION_COLORS } from "@/components/playground/CitationCard"; import type { ValueSource } from "@a2ui-sdk/types/0.8"; +type A2UIComponentProps = T & { + surfaceId: string; + componentId: string; + weight?: number; +}; + interface A2UICitationCardProps { title: ValueSource; excerpt: ValueSource; diff --git a/frontend/src/components/a2ui/catalog/A2UIRewriteDiff.tsx b/frontend/src/components/a2ui/catalog/A2UIRewriteDiff.tsx index c228db4..5e20418 100644 --- a/frontend/src/components/a2ui/catalog/A2UIRewriteDiff.tsx +++ b/frontend/src/components/a2ui/catalog/A2UIRewriteDiff.tsx @@ -6,13 +6,16 @@ */ import { memo } from "react"; -import { - useDataBinding, - type A2UIComponentProps, -} from "@a2ui-sdk/react/0.8"; +import { useDataBinding } from "@a2ui-sdk/react/0.8"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"; import type { ValueSource } from "@a2ui-sdk/types/0.8"; +type A2UIComponentProps = T & { + surfaceId: string; + componentId: string; + weight?: number; +}; + interface A2UIRewriteDiffProps { original: ValueSource; rewritten: ValueSource; diff --git a/frontend/src/components/a2ui/catalog/A2UIStatsDashboard.tsx b/frontend/src/components/a2ui/catalog/A2UIStatsDashboard.tsx index cbac979..6f146ad 100644 --- a/frontend/src/components/a2ui/catalog/A2UIStatsDashboard.tsx +++ b/frontend/src/components/a2ui/catalog/A2UIStatsDashboard.tsx @@ -6,13 +6,16 @@ */ import { memo } from "react"; -import { - useDataBinding, - type A2UIComponentProps, -} from "@a2ui-sdk/react/0.8"; +import { useDataBinding } from "@a2ui-sdk/react/0.8"; import { FileText, Users, Calendar, TrendingUp } from "lucide-react"; import type { ValueSource } from "@a2ui-sdk/types/0.8"; +type A2UIComponentProps = T & { + surfaceId: string; + componentId: string; + weight?: number; +}; + interface StatItem { label: string; value: string | number; diff --git a/frontend/src/components/playground/MessageBubble.tsx b/frontend/src/components/playground/MessageBubble.tsx index db7aba8..4ad6774 100644 --- a/frontend/src/components/playground/MessageBubble.tsx +++ b/frontend/src/components/playground/MessageBubble.tsx @@ -60,23 +60,25 @@ function MessageBubble({ const rehypePlugins = useMemo(() => [rehypeKatex, rehypeHighlight], []); const markdownComponents = useMemo( - () => ({ - "citation-ref": ({ - index: citationIndex, - }: { - index?: number; - children?: React.ReactNode; - }) => { - if (citationIndex == null) return null; - return ( - - ); - }, - }), + () => + ({ + "citation-ref": ({ + index: citationIndex, + }: { + index?: number; + children?: React.ReactNode; + }) => { + if (citationIndex == null) return null; + return ( + + ); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, [citationMap, handleClickCitation], ); diff --git a/frontend/src/components/playground/__tests__/MessageBubble.test.tsx b/frontend/src/components/playground/__tests__/MessageBubble.test.tsx index f8a532e..6077e94 100644 --- a/frontend/src/components/playground/__tests__/MessageBubble.test.tsx +++ b/frontend/src/components/playground/__tests__/MessageBubble.test.tsx @@ -41,7 +41,7 @@ describe('MessageBubble', () => { chunk_type: 'abstract', page_number: 5, relevance_score: 0.95, - snippet: 'relevant text', + excerpt: 'relevant text', }, ]; renderWithProviders( diff --git a/frontend/src/pages/PlaygroundPage.tsx b/frontend/src/pages/PlaygroundPage.tsx index 9d7f34e..e1fb215 100644 --- a/frontend/src/pages/PlaygroundPage.tsx +++ b/frontend/src/pages/PlaygroundPage.tsx @@ -89,7 +89,7 @@ export default function PlaygroundPage() { }, [messages]); const pendingDeltaRef = useRef(''); - const flushTimerRef = useRef>(); + const flushTimerRef = useRef | undefined>(undefined); const assistantIdRef = useRef(''); const flushDelta = useCallback(() => { diff --git a/frontend/src/pages/project/PapersPage.tsx b/frontend/src/pages/project/PapersPage.tsx index c001cbd..280a162 100644 --- a/frontend/src/pages/project/PapersPage.tsx +++ b/frontend/src/pages/project/PapersPage.tsx @@ -96,7 +96,7 @@ export default function PapersPage() { invalidateKeys: [['papers', pid], ['project', projectId]], }); - const ocrMutation = useToastMutation({ + const _ocrMutation = useToastMutation({ mutationFn: (paperIds: number[]) => ocrApi.process(pid, paperIds), successMessage: t('papers.ocrSuccess'), errorMessage: t('papers.ocrFailed'), From 093a6a6d26b72d2f6f5384660e21d74c31f2f0d6 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Thu, 12 Mar 2026 17:41:00 +0800 Subject: [PATCH 7/8] fix(frontend): resolve A2UI catalog type mismatch and remove unused ocrMutation - Cast custom A2UI components to CatalogComponent (runtime props injected by framework) - Remove unused ocrMutation and ocrApi import from PapersPage Made-with: Cursor --- frontend/src/components/a2ui/catalog/index.ts | 9 ++++++--- frontend/src/pages/project/PapersPage.tsx | 9 +-------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/a2ui/catalog/index.ts b/frontend/src/components/a2ui/catalog/index.ts index ff4457c..08c5335 100644 --- a/frontend/src/components/a2ui/catalog/index.ts +++ b/frontend/src/components/a2ui/catalog/index.ts @@ -1,17 +1,20 @@ import { standardCatalog, type Catalog, + type CatalogComponent, } from "@a2ui-sdk/react/0.8"; import A2UICitationCard from "./A2UICitationCard"; import A2UIRewriteDiff from "./A2UIRewriteDiff"; import A2UIStatsDashboard from "./A2UIStatsDashboard"; +// A2UI framework injects component-specific props from the surface definition +// at runtime, so catalog entries are safely cast to CatalogComponent. export const omeletteCatalog: Catalog = { ...standardCatalog, components: { ...standardCatalog.components, - OmeletteCitationCard: A2UICitationCard, - OmeletteRewriteDiff: A2UIRewriteDiff, - OmeletteStatsDashboard: A2UIStatsDashboard, + OmeletteCitationCard: A2UICitationCard as unknown as CatalogComponent, + OmeletteRewriteDiff: A2UIRewriteDiff as unknown as CatalogComponent, + OmeletteStatsDashboard: A2UIStatsDashboard as unknown as CatalogComponent, }, }; diff --git a/frontend/src/pages/project/PapersPage.tsx b/frontend/src/pages/project/PapersPage.tsx index 280a162..9a05f86 100644 --- a/frontend/src/pages/project/PapersPage.tsx +++ b/frontend/src/pages/project/PapersPage.tsx @@ -19,7 +19,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { paperApi, ocrApi, projectApi, paperProcessApi } from '@/services/api'; +import { paperApi, projectApi, paperProcessApi } from '@/services/api'; import { kbApi } from '@/services/kb-api'; import type { Paper, PaperStatus } from '@/types'; import type { UploadResult, DedupConflictPair } from '@/services/kb-api'; @@ -96,13 +96,6 @@ export default function PapersPage() { invalidateKeys: [['papers', pid], ['project', projectId]], }); - const _ocrMutation = useToastMutation({ - mutationFn: (paperIds: number[]) => ocrApi.process(pid, paperIds), - successMessage: t('papers.ocrSuccess'), - errorMessage: t('papers.ocrFailed'), - invalidateKeys: [['papers', pid], ['project', projectId]], - }); - const papers: Paper[] = data?.items ?? []; const total = data?.total ?? 0; From 40f0cfad56b2b24c29d4ec05e74634bfb2108afa Mon Sep 17 00:00:00 2001 From: sylvanding Date: Thu, 12 Mar 2026 17:46:05 +0800 Subject: [PATCH 8/8] fix(frontend): update tests for ChatInput multi-button and SettingsPage mock provider - Add aria-label to ChatInput submit button for accessibility - Use specific button name selector in ChatInput tests (attach vs send) - Fix SettingsPage test: verify test-connection hidden for mock provider Made-with: Cursor --- frontend/src/components/playground/ChatInput.tsx | 1 + .../src/components/playground/__tests__/ChatInput.test.tsx | 6 +++--- frontend/src/pages/__tests__/SettingsPage.test.tsx | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/playground/ChatInput.tsx b/frontend/src/components/playground/ChatInput.tsx index 58e07f9..d1a9cea 100644 --- a/frontend/src/components/playground/ChatInput.tsx +++ b/frontend/src/components/playground/ChatInput.tsx @@ -103,6 +103,7 @@ export default function ChatInput({