diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79a586d2..f39ee39e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,11 +95,14 @@ jobs: - name: Install monorepo deps run: pnpm install --frozen-lockfile || pnpm install + # Use the packages' own build scripts — they also generate compat.css, + # shadcn.css and copy the voice/canvas subentry styles into dist/. + # Hand-rolling tsc+tailwind here drifts the moment a package adds a + # build step (exactly what broke ./compat.css resolution in 0.2.0). - name: Build packages run: | - cd packages/jcode-ui-core && npx tsc -p tsconfig.build.json - cd ../jcode-ui && npx tsc -p tsconfig.build.json - npx tailwindcss -i src/styles/entry.css -o dist/styles.css --minify + pnpm -C packages/jcode-ui-core build + pnpm -C packages/jcode-ui build - name: Type-check web app working-directory: web diff --git a/Makefile b/Makefile index a9b26c3b..9d854616 100644 --- a/Makefile +++ b/Makefile @@ -45,9 +45,8 @@ generate: build-web: generate @echo "Building React frontend + packages..." -pnpm install --frozen-lockfile 2>/dev/null || true - cd packages/jcode-ui-core && npx tsc -p tsconfig.build.json - cd packages/jcode-ui && npx tsc -p tsconfig.build.json - cd packages/jcode-ui && npx tailwindcss -i src/styles/entry.css -o dist/styles.css --minify + cd packages/jcode-ui-core && pnpm build + cd packages/jcode-ui && pnpm build cd web && npx vite build # The main binary never links CoreBluetooth (whose eager init triggers the macOS diff --git a/examples/jcode-ui-minimal/package.json b/examples/jcode-ui-minimal/package.json index 3099afd4..42a952c7 100644 --- a/examples/jcode-ui-minimal/package.json +++ b/examples/jcode-ui-minimal/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "dependencies": { - "jcode-ui": "^0.1.1", - "jcode-ui-core": "^0.1.0", + "jcode-ui": "^0.2.0", + "jcode-ui-core": "^0.2.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/jcode-ui-zustand/package.json b/examples/jcode-ui-zustand/package.json index 0e4a7cef..1446dd55 100644 --- a/examples/jcode-ui-zustand/package.json +++ b/examples/jcode-ui-zustand/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "dependencies": { - "jcode-ui": "^0.1.1", - "jcode-ui-core": "^0.1.0", + "jcode-ui": "^0.2.0", + "jcode-ui-core": "^0.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^5.0.3" diff --git a/internal-doc/chat-ui-competitive-analysis.md b/internal-doc/chat-ui-competitive-analysis.md new file mode 100644 index 00000000..5083566f --- /dev/null +++ b/internal-doc/chat-ui-competitive-analysis.md @@ -0,0 +1,86 @@ +# jcode-ui 竞品设计对比(2026-07) + +对比对象:**assistant-ui**(YC,11k★)、**Vercel AI Elements**(2.2k★,npm CLI ~39.5k/周)、**CopilotKit**(35.9k★,$27M A 轮)。 +视角:设计 —— 完整度 + 视觉质感。数据来自 2026-07 官方文档/GitHub 调研 + jcode-ui 源码走读 + fixture 实际渲染。 + +--- + +## 1. 四家定位一句话 + +| 库 | 定位 | 分发方式 | 硬依赖 | +|---|---|---|---| +| **jcode-ui** | coding-agent 会话 UI(工具调用/审批/ask-user 深耕) | npm 包(styled + headless core 双包) | 无(消费端只 import CSS,不要求 Tailwind) | +| **assistant-ui** | "ChatGPT 的 UX 装进你的 React 应用",通用 chat 全家桶 | npm primitives + shadcn registry copy-in | Tailwind + shadcn(styled 层) | +| **AI Elements** | AI SDK 官方配套 UI,五大类 48 组件 | 纯 shadcn copy-in(npm 只是安装器) | Next.js + AI SDK + shadcn + Tailwind 4 + React 19 | +| **CopilotKit** | "Agent 前端栈",AG-UI 协议 + 生成式 UI + HITL | npm 包(v2 shadcn 化) | 架构绑定重(runtime + 协议) | + +## 2. 完整度矩阵 + +✅ 有 · 🟡 部分/产品层 · ❌ 无 + +| 能力 | jcode-ui | assistant-ui | AI Elements | CopilotKit | +|---|---|---|---|---| +| Thread(虚拟化+auto-follow) | ✅ | ✅ | ✅(stick-to-bottom) | ✅(TanStack Virtual) | +| Composer(附件/停止/快捷键) | ✅ +slash+队列 | ✅ +slash+@mention+历史+引用选区 | ✅ +截屏+语音输入+模型选择 | ✅ +语音转写+建议 | +| 消息编辑 | ✅ | ✅ | ✅ | ✅ | +| **分支切换(branch picker)** | ❌ | ✅ | ✅ | ✅ | +| Regenerate / feedback(👍👎) | ❌ | ✅ | ✅ | ✅ | +| 建议 pills(空态/跟进) | ❌ | ✅(+AI 生成) | ✅ | ✅(+AI 生成) | +| Reasoning/CoT | ✅ | ✅(2 种) | ✅(2 种) | ✅ | +| Sources/引用 | ✅ | ✅ | ✅ +inline citation hover | 🟡 | +| 工具调用卡片 | ✅ 注册表+9 renderer | ✅ toolkit+fallback+group | ✅ Tool(4 状态) | ✅ Zod schema 渲染 | +| **终端/diff/文件树等编码域渲染** | ✅ 深(runtime-wired,双通道 stdout/stderr+exit+耗时+截断) | 🟡 Diff Viewer | ✅ 广(15 个 Code 组件,但纯展示) | ❌ | +| 审批/HITL | ✅ 两步 arming+外部路径标记 | ✅ requires-action/approval | ✅ Confirmation(薄) | ✅ useHumanInTheLoop 状态机 | +| ask-user 中途提问 | ✅ 一等公民 | 🟡(interrupt 拼装) | ❌ | 🟡(interrupt) | +| Token/上下文用量 | ✅ ContextBar 环+明细 popover | ✅ Context Display | ✅ +成本估算(tokenlens) | ❌ | +| 计划/任务(todo/plan) | ✅ todo renderer+goal | 🟡 | ✅ Plan/Task/Queue/Checkpoint | 🟡 shared state | +| 子代理/多 agent 嵌套 | ✅ children 递归+team renderer+exploring 聚合 | 🟡 multi-agent 指南 | 🟡 Agent 卡片 | ✅ 协议级 | +| 生成式 UI(模型产出组件) | ❌ | ✅ JSON spec+编译校验 | ✅ JSXPreview | ✅ A2UI/MCP Apps | +| 语音 | ❌(明确不做) | ✅ | ✅ 6 组件套件 | ✅ 转写输入 | +| Mermaid/LaTeX/Streamdown | ❌(marked+hljs) | ✅ 全有 | ✅(Streamdown+KaTeX) | ✅(KaTeX) | +| ThreadList/历史 | 🟡 产品层 | ✅ +Cloud 持久化 | ❌ | ✅ 企业版 | +| 白板/工作流画布 | ❌ | ❌ | ✅ React Flow 7 组件 | 🟡 canvas demo | +| 移动/终端端 | ❌ | ✅ RN + ink | ❌ | ✅ RN+Angular+Vue | +| DevTools | ❌ | ✅ | ❌ | ✅ inspector | + +**量化感受**:通用广度 AI Elements(48 组件)> assistant-ui > CopilotKit > jcode-ui(12 styled + 6 headless + 9 renderer);but 编码 agent 纵深 jcode-ui 第一 —— 双通道终端、exploring 聚合、subagent 树、两步审批、ask-user,这五件事三家都没有做到你这个完成度,AI Elements 的 Code 套件组件多但都是静态展示件,没接 runtime 语义。 + +## 3. 架构/主题化对比 + +- **jcode-ui**:双包(styled/headless)+ ChatRuntime(ExternalStore/Mock)+ ToolRendererRegistry。与 assistant-ui 的三层架构(UI/Runtime/Backend)同构,规模小一个数量级(~6k 行)。主题 = 纯 CSS 变量(radius/shadow/motion/z-index/accent-wash/neutral-wash/hljs×2/xterm×2 全 token 化),`.dark` class 切换,color-mix 派生 wash。**消费端零 Tailwind 依赖**是差异点(三家 styled 层全绑 Tailwind/shadcn)。 +- **assistant-ui**:headless primitives(npm)+ styled(shadcn copy-in,代码归你)。定制梯子:改源码 → components prop → 换 renderer → 纯 headless。 +- **AI Elements**:全 copy-in,继承宿主 shadcn 主题("existing themes apply automatically"),自身零 token 体系。 +- **CopilotKit v2**:oklch shadcn token 挂 `[data-copilotkit]`,五级定制梯(token → Tailwind slot → props → 换组件 → headless)。 + +jcode-ui 的取舍:npm 包 + CSS 变量 = 升级容易、定制边界窄(改不了 DOM 结构,只能覆 token 或下沉 headless 自己拼)。shadcn copy-in 派 = 定制无上限、升级靠手动 diff。两条路线都成立,但 **shadcn 生态互操作缺失**(`--color-primary` vs shadcn `--primary`)会挡住最大的潜在用户群。 + +## 4. 好看程度(视觉质感) + +- **三家默认皮全部是 shadcn 中性风**:assistant-ui 刻意仿 ChatGPT;AI Elements 是 flat shadcn(user 气泡 secondary 底、assistant 全宽平铺);CopilotKit v2 是 Radix+Lucide 的标准干净。共同问题:同质化,"AI 应用长得都一样"。 +- **jcode-ui 有真实的自有品牌语言**:暖橙 accent 但克制使用(orange 只给 hero/send,其余控件走 neutral wash,"calm and monochrome");warm-tinted 阴影梯;Geist + JetBrains Mono;无气泡 flat 消息布局(avatar+role label+全宽 prose,更像 Claude/Linear 的编辑器感);终端卡失败态橙描边 + exit code 语义色。fixture 实测:exploring 组、终端双通道、截断标记的排版都干净利落。 +- 结论:**辨识度你赢,普适性他们赢**。作为独立库推广时,"有个性的默认皮"是双刃剑 —— 用户第一眼印象好,但想融入自家品牌时你只有 token 覆盖一条路。 +- 实测小瑕疵:消息文本与下方工具卡之间垂直空白偏大(fixture 中 message.detail 空段落?值得查);Thread 内滚动在嵌入宿主页时手感需要再验证。 + +## 5. 生态信号(客观差距) + +| | jcode-ui | assistant-ui | AI Elements | CopilotKit | +|---|---|---|---|---| +| Stars | ~0(内部) | 11k | 2.2k(CLI 39.5k DL/周) | 35.9k(AG-UI 14.7k) | +| 资金 | — | YC pre-seed | Vercel | $27M Series A | +| 文档 | site docs+live demo+生成 API(**但线上 /chat-ui 404,部署落后**) | llms.txt+25 指南+deprecation policy | 每组件 live preview+Figma kit+agent skill | 全面但 v1/v2 混杂 | +| 迭代 | 内部节奏 | 高频 | 11 个月 20 release | 高频 | + +## 6. 差距收敛建议(按 ROI) + +1. **门面**:重新部署 site —— README 第一个链接(live demo)现在是 404。 +2. **Branch picker + regenerate + 👍👎**:表驱动数据结构已支持 editMessage,补 UI 成本低,是"完整 chat 库"的及格线三件套。 +3. **建议 pills(空态+跟进)**:感知强、成本低。 +4. **Streaming markdown 渐进渲染**(Streamdown 式)+ Mermaid/KaTeX:文档/研究型输出场景的硬需求。 +5. **shadcn 互操作层**:发一个 token 别名映射(shadcn `--primary` → `--color-primary`)或 Tailwind preset,吃到最大用户池。 +6. 不建议跟进:语音、workflow 画布、多端 —— 与 coding-agent 定位无关,是三家的规模游戏。 + +## 7. 顺手发现的问题 + +- www.j-code.net 部署落后于仓库:`/chat-ui` 路由 404(README/docs 多处链接指向)。 +- `site` dev 启动报错:`three` 被 `site/public/showcase-projects/city3d/js/city.js` 引用但未安装,vite dep-scan 失败,首页 `` 组件连锁报错。 +- `.claude/launch.json` 新增了 `jcode-ui-fixture` 配置(经 `web/fixture-tool-ux` 跑 vite,端口 5199),可视化验收 jcode-ui 渲染。 diff --git a/internal-doc/chat-ui-roadmap.md b/internal-doc/chat-ui-roadmap.md new file mode 100644 index 00000000..e16be211 --- /dev/null +++ b/internal-doc/chat-ui-roadmap.md @@ -0,0 +1,107 @@ +# jcode-ui 提升路线图(2026-07) + +> **状态(2026-07-11)**:P0–P6 + workflow 画布 + 语音套件 + site 增强已全部落地,双包 0.2.0(未发 npm),site 已部署(/chat-ui 200)。剩余:npm publish、cloud console 升级接入(删 PermissionCard)、Checkpoint(5.5,依赖后端 rewind)。变更明细见 packages/jcode-ui/CHANGELOG.md。 + +来源:两份独立评审的合并 —— ① 本仓库竞品分析([chat-ui-competitive-analysis.md](chat-ui-competitive-analysis.md),对比 assistant-ui / AI Elements / CopilotKit);② 外部审计(评分:通用完整度 6.5 / coding-agent 垂直 8.0 / 默认好看 7.6 / 定制上限 8.2)。两份结论一致:**差距不在 CSS,在产品状态闭环、组件广度和展示包装;强项是 runtime 底座 + coding-agent 纵深 + 品牌辨识度,不需要推倒重来。** + +**战略**:不复制 AI Elements 的 48 个组件,做"最好用的 coding-agent UI"。保住审批安全、工具执行清晰度、subagent/长任务时间线三个优势,补 assistant-ui 的会话闭环与 AI Elements 的视觉完整度,再以 AG-UI adapter 把适用面扩到所有 agent 后端。 + +**三个受益方**: +- **jcode**(web/desktop,workspace 直连) +- **cloud console**(`/Users/jack/workpath/jjj/cloud/console`,npm 锁 0.1.1;现状:headless Thread + Message/ToolCallCard 直用,但 ApprovalBanner 契约不够用,自维护 PermissionCard;自有 `--jc-*`+语义 `--color-*` token 与库的 `:root --color-*` 并存) +- **生态推广**(npm 已发布 0.1.x,目标:任何 AI agent 的 React 前端) + +依赖主线:`P0 门面 → P1 库安全化(scoping/approval 泛化)→ P2 会话闭环 → P3/P4/P5 并行 → P6 远期`。P1 是推广和 cloud 收敛的前置,必须早做(越晚破坏面越大)。 + +--- + +## P0 — 门面与信任(推广的前提,全部 S/M) + +| # | 事项 | 细节 | 验收 | 量 | +|---|---|---|---|---| +| 0.1 | **修线上 404** | www.j-code.net 部署落后,`/chat-ui` 与 `/chat-ui/docs` 均 404;README 第 5 行 Live demo/Docs 链接全挂。先修 0.2 再用 release-site skill 发布。已建后台任务卡片(task_49d80d13)。 | 两个 URL 200;README 链接可点 | S | +| 0.2 | **修 site dev 报错** | `three` 被 `site/public/showcase-projects/city3d/js/city.js` 引用,vite dep-scan 失败;首页 `` 连锁 React 报错。public/ 下的裸 js 不该被扫描,查 `site/vite.config.ts` 排除,或补依赖。 | `pnpm -C site dev` 首页与 /chat-ui 控制台零报错 | S | +| 0.3 | **Demo 从"自动播放"升级为"可交互产品感"** | 现 demo 播完即静。加:Welcome 空态、Suggestions 起始动作、可输入的 mock 会话(mock runtime 已支持 scriptable)、**light/dark/mobile 三态切换**展示、streaming 回放按钮。 | /chat-ui 页可切三态、可交互发消息 | M | +| 0.4 | **修消息→工具卡垂直空白偏大** | fixture 实测 message 文本与下方 tool 卡之间空白明显(疑 `.jcode-message` padding 与 tool shell margin 叠加,或空 detail 段落)。查 `components.css` §Message polish / §Tool call shell。 | fixture 目测节奏紧凑;web 不回归 | S | +| 0.5 | **components.md 对照表更新** | `site/docs/chat-ui/components.md` 的 assistant-ui 映射表把本路线图的新组件补进"计划中"列,做 roadmap 透明化(assistant-ui 的 deprecation policy 是信任信号,学)。 | 表格含 P2/P3 项的 planned 状态 | S | + +## P1 — 第三方安全化(cloud 直接受益;0.2.0 breaking 窗口) + +| # | 事项 | 细节 | 验收 | 量 | +|---|---|---|---|---| +| 1.1 | **Token 范围化** | `:root`/`.dark` 全局注入 → `[data-jcode-ui]` 作用域 + `--jcode-*` 前缀(外部审计核心意见)。涉及:`tokens.css`(292 行全量)、`components.css`(1184 行 var 引用)、`animations.css`、组件 inline style 的 var()。dark 模式:`[data-jcode-ui].dark, .dark [data-jcode-ui]` 双选择器(参考 CopilotKit `[data-copilotkit]` 方案)。发 `compat.css` 把旧 `--color-*` alias 到新名,保一个 minor 周期。**注意**:jcode web 自身 theme 生成器(Go palette→CSS)同步改输出前缀。 | cloud console 引入后 `lint:tokens` 通过、`--color-*` 语义命名不再撞车;jcode web 全主题(dracula/nord…)不回归;宿主页面 body 样式零污染 | L | +| 1.2 | **shadcn 互操作桥** | 发可选 `shadcn-bridge.css`:`[data-jcode-ui] { --jcode-primary: var(--primary); --jcode-background: var(--background); … }`(≈15 个映射)。让 shadcn 用户零配置继承宿主主题——吃最大用户池的最低成本动作。 | shadcn 模板项目 import 两个 css 后主题自动一致 | S | +| 1.3 | **Approval 契约泛化** | 现 `Approval` 只有 boolean approved;cloud 的 ACP `permission_request` 是任意 option ID,被迫自维护 PermissionCard(`cloud/console/src/runview/PermissionCard.tsx`)。改:`Approval.options?: ApprovalOption[]`(`{id, label, kind: 'allow_once'|'allow_always'|'deny'|'custom', description?}`),`resolveApproval(id, optionId)`;无 options 时回退现有 allow/deny 渲染(含两步 arming)。ApprovalBanner 渲染 N 选项,`allow_always` 保留 arming 交互。 | cloud 删除自有 PermissionCard 改用库组件;jcode web 不回归;类型向后兼容 | M | +| 1.4 | **ExternalStoreRuntime actions 扩展** | 承接 1.3 的 `resolveApproval` 新签名;同时为 P2 预留 `regenerate`/`submitFeedback`/`retry` 的可选 action 位(全部 optional,host 不实现则 UI 不渲染对应按钮——沿用 canEdit 的 fail-visible 惯例)。 | 类型编译通过;未实现 action 时按钮不出现 | S | +| 1.5 | **发布纪律** | 0.2.0 起:CHANGELOG.md、SemVer 承诺、deprecation policy 页(docs)、迁移指南(0.1→0.2 token 更名表)。 | npm 0.2.0 发布;docs 有 migration 页 | S | + +## P2 — 会话闭环(两个产品的及格线三件套+) + +| # | 事项 | 细节 | 验收 | 量 | +|---|---|---|---|---| +| 2.1 | **Branch picker + regenerate** | 数据层:`Message.versions?: MessageVersion[]` + `activeVersion`(不动 ThreadItem 结构,版本挂在消息上,编辑/重生成都产生新 version);runtime action `regenerate(messageId)`、`switchVersion(messageId, versionId)`。UI:`‹ 2/3 ›` BranchPicker(hover 显示,复用 msg-actions 槽)。jcode 后端已有 editMessage 重放链路,cloud 无 → cloud 不实现 action 即不渲染(fail-visible)。 | mock runtime 演示分支切换;jcode web 编辑消息后可回看旧版本 | L | +| 2.2 | **Feedback(👍👎)** | `submitFeedback(messageId, 'up'|'down', comment?)` 可选 action + msg-actions 两个按钮 + 已提交态。host 决定落库(jcode 本地 jsonl / cloud API)。 | 按钮出现且状态持久(会话内) | S | +| 2.3 | **Error/retry + 连接状态** | ① assistant 消息失败态:`Message.level='error'` 已有,补 Retry 按钮(action `retry(messageId)`)。② 新组件 `ConnectionBanner`:`disconnected/reconnecting/reconnected` 三态(cloud SSE 断线、jcode ws 重连直接受益),吸附 Thread 顶部,token 化配色用 warning/info。 | 断网模拟下 banner 三态正确;retry 重发成功 | M | +| 2.4 | **Welcome + Suggestions** | 新组件 `ThreadWelcome`(logo slot + 标题 + 副标题)与 `Suggestions`(pill 列表;两个位置:空态 starter、turn 结束 follow-up)。数据:host 传静态列表或回调 `getSuggestions(ctx)`(AI 生成留给 host)。Thread 空态自动渲染 Welcome(可关)。 | 空态不再是白屏;点击 pill 即发送 | M | +| 2.5 | **会话导出** | `exportMarkdown(items)` 纯函数(core)+ Thread 头部可选下载按钮。工具调用导出为折叠代码块,审批导出为引用块。 | 导出的 md 在 GitHub 渲染正常 | S | +| 2.6 | **Quote 选区引用** | 选中消息文本浮出"引用回复"按钮 → composer 前置 `> …` 引文块。参考 assistant-ui SelectionToolbar。低风险可后置。 | 选区引用进 composer | M | + +## P3 — Composer 二代 + +| # | 事项 | 细节 | 验收 | 量 | +|---|---|---|---|---| +| 3.1 | **通用文件附件 + AttachmentAdapter** | 现仅 base64 图片。定义 `AttachmentAdapter`(`add(file) → {id, kind, name, size, url?, progress$}`,host 决定上传/内联),支持 pdf/文本/任意文件 chip(类型图标+大小),图片走现有 ChatImage 快路径。上传进度条 + 失败重试。 | cloud(有对象存储)与 jcode(本地内联)各接一个 adapter 跑通 | L | +| 3.2 | **拖拽 + 粘贴** | drop zone 覆盖 Thread+Composer,粘贴截图直接成附件(AI Elements 的 add-screenshot 对标,但作为粘贴而非主动截屏)。 | 拖 3 类文件、⌘V 截图均成 chip | M | +| 3.3 | **Composer slots** | `ChatInput` 增加 `leadingControls`/`trailingControls`/`footer` 三个 slot props(外部审计"styled 层组合粒度不够"的最小解——一层 slot,不做递归)。jcode web 的 model/mode/workspace picker 从产品层移入 slot 用法示范;库内提供可选 `ModelSelector` styled 件(数据 host 供给)。 | jcode web 用 slot 装回现有 picker,零视觉回归 | M | +| 3.4 | **语音输入(可选,最后)** | Web Speech API 听写按钮,`enableDictation` 开关,默认关。纯增强,不做 voice 会话(与定位一致;仅为"通用库"补票)。 | Chrome 下听写进 textarea | M | + +## P4 — 富渲染 + +| # | 事项 | 细节 | 验收 | 量 | +|---|---|---|---|---| +| 4.1 | **Streaming markdown 渐进渲染** | 现 marked 全量重渲。改:按块增量解析,未闭合 fence/表格稳定渲染不闪(Streamdown 思路);流式中代码块尾部 shimmer(animations.css 已有 shimmer 基建)。 | 长代码流式无闪烁;CPU 火焰图无全量重排 | L | +| 4.2 | **代码块 chrome** | 文件名/语言头 + 复制按钮 + 行号可选(现在纯 pre+hljs,无任何 chrome,已确认)。 | md 代码块带头部与 copy | S | +| 4.3 | **Mermaid + KaTeX 可选插件** | `jcode-ui/plugins/mermaid`、`/katex` 子入口,peer dep 动态 import,不进主包(守住 tree-shakeable 卖点)。 | 不装插件包体不变;装后图表/公式渲染 | M | + +## P5 — 组件位阶提升(coding-agent 纵深变现) + +| # | 事项 | 细节 | 验收 | 量 | +|---|---|---|---|---| +| 5.1 | **TaskList 独立组件** | todo renderer(189 行)→ 一等 `TaskList`(compound:Item/Status/Progress),runtime `todos` 已在 RuntimeState。cloud run 详情页、jcode goal 面板复用。 | 两产品各一处落地 | M | +| 5.2 | **FileTree / TestResults / StackTrace renderer** | 对标 AI Elements Code 套件但 runtime-wired:FileTree(list_dir/glob 输出)、TestResults(go test/vitest 解析)、StackTrace(panic/throw 高亮+路径可点)。注册进 default registry。 | fixture 三个新 renderer 各一屏 | L | +| 5.3 | **Artifact 容器** | 生成物卡片(标题+类型+动作条+内容区),先做卡片形态,不做侧栏画布(那是产品层)。file-viewer/diff 可作为 Artifact 内容复用。 | mock 演示"生成一个文件"场景 | M | +| 5.4 | **Message/ToolCallCard slots** | 与 3.3 同思路:`avatar`/`header`/`footer`/`actions` 一层 slot。cloud 的 AttributedUserMessage(author 署名)就是现成需求——slot 化后 cloud 删自有包装。 | cloud Timeline 改用 slot,删 AttributedUserMessage | M | +| 5.5 | **Checkpoint(依赖后端)** | 会话回滚标记 UI。**前置**:jcode 后端 rewind 能力;无则不做纯摆设 UI。先立项调研,不排期。 | — | ? | + +## P6 — Agent-App 协作(远期,一季度后再评估) + +| # | 事项 | 细节 | 量 | +|---|---|---|---| +| 6.1 | **AG-UI runtime adapter** | `createAGUIRuntime(endpoint)`:AG-UI 16 事件 → RuntimeState 映射。杠杆:AG-UI 生态(LangGraph/CrewAI/Mastra/Bedrock…)的 UI 侧现在只有 CopilotKit/assistant-ui 两个选择,这是"推广给所有 agent"的最短路径,让非 jcode 后端零胶水接入。 | L | +| 6.2 | **ThreadList 契约** | `ThreadStore` 接口(list/create/rename/archive)+ 默认侧栏 UI。jcode session 列表与 cloud run 列表是两个现成实现。 | L | +| 6.3 | shared agent state / typed generative registry / interrupt contract | CopilotKit 对标,等 1-6.2 站稳再议。 | — | + +## 生态推广 workstream(与功能并行,都是 S) + +| # | 事项 | 细节 | +|---|---|---| +| E1 | npm 包面:keywords(`ai`,`chat`,`agent`,`react`,`assistant-ui-alternative`…)、README badges、社交卡图 | +| E2 | **对比页**:docs 增 "vs assistant-ui / AI Elements / CopilotKit" 诚实对比(何时选谁),SEO 主入口 | +| E3 | **agent skill**:发 `jcode-ui` 使用 skill(对标 `npx skills add vercel/ai-elements`),llms.txt 已有,补组件级 llms-full | +| E4 | StackBlitz 一键模板 ×3:minimal(mock)/zustand/AG-UI(6.1 后) | +| E5 | 发布节奏公开:GitHub Releases + changelog 页;每个 minor 一篇短 blog(site 已有 changelog.md 基建) | +| E6 | 首发内容:0.2.0(scoping)+ 0.3.0(会话闭环)后做一轮 Show HN / X 发布;演示视频用 fixture 录 | + +## 排期建议(单人节奏) + +- **第 1-2 周**:P0 全部 + 1.5(门面即刻可信) +- **第 3-5 周**:1.1–1.4(0.2.0 发版;cloud 同步升级、删 PermissionCard) +- **第 6-9 周**:P2(0.3.0;jcode web 全量接入)+ E1/E2 +- **第 10-13 周**:P3(0.4.0)+ E3/E4 +- **之后按季度**:P4 → P5 → P6,每阶段先在 jcode/cloud 落地再发版(dogfood-first) + +## 明确不做 + +- 语音会话套件、workflow 画布、RN/多框架端(规模游戏,偏离定位) +- shadcn copy-in 分发切换(保 npm 包 + token/slot 定制路线;copy-in 的升级成本对两个自家产品是负资产) +- 48 组件军备竞赛 diff --git a/packages/jcode-ui-core/package.json b/packages/jcode-ui-core/package.json index e34a49b7..97ef4189 100644 --- a/packages/jcode-ui-core/package.json +++ b/packages/jcode-ui-core/package.json @@ -1,6 +1,6 @@ { "name": "jcode-ui-core", - "version": "0.1.0", + "version": "0.2.0", "description": "Framework-agnostic core for jcode-ui: types, chat runtime abstraction, and headless React primitives for AI chat interfaces.", "type": "module", "license": "MIT", @@ -64,6 +64,7 @@ "build": "tsc -p tsconfig.build.json", "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", "typecheck": "tsc --noEmit -p tsconfig.json", + "selftest:agui": "pnpm run build && node dist/runtime/agui.selftest.js", "clean": "rm -rf dist *.tsbuildinfo" }, "peerDependencies": { diff --git a/packages/jcode-ui-core/src/export/markdown.ts b/packages/jcode-ui-core/src/export/markdown.ts new file mode 100644 index 00000000..1f65b8af --- /dev/null +++ b/packages/jcode-ui-core/src/export/markdown.ts @@ -0,0 +1,123 @@ +/** + * exportThreadMarkdown — serialize a conversation timeline to portable + * GitHub-flavored markdown. + * + * Pure and deterministic: no clock access (pass `now` for the header stamp) + * and no DOM. Tool calls fold into
so long transcripts stay + * scannable; fences inside tool output are escaped by widening the outer + * fence, never by mangling the content. + */ + +import type { ThreadItem, ToolCall, Message, Approval, ExploringGroup } from '../types/index.js' + +export interface ExportMarkdownOptions { + /** Document H1. Default: "Conversation". */ + title?: string + /** Timestamp for the header line; omitted → no stamp (deterministic). */ + now?: Date + /** Truncate a single tool output beyond this many chars. Default 4000. */ + maxToolOutput?: number + /** Role display labels. */ + labels?: { user?: string; assistant?: string; system?: string } +} + +/** Pick a fence longer than any run of backticks inside `body`. */ +function fenceFor(body: string): string { + const longest = body.match(/`{3,}/g)?.reduce((m, s) => Math.max(m, s.length), 0) ?? 0 + return '`'.repeat(Math.max(3, longest + 1)) +} + +function codeBlock(body: string, lang = ''): string { + const fence = fenceFor(body) + return `${fence}${lang}\n${body}\n${fence}` +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s + return `${s.slice(0, max)}\n… (${s.length - max} chars truncated)` +} + +function prettyJSON(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2) + } catch { + return raw + } +} + +function renderMessage(m: Message, labels: Required>): string { + const label = m.role === 'user' ? labels.user : m.role === 'assistant' ? labels.assistant : labels.system + const parts = [`### ${label}`] + if (m.reasoning) { + parts.push(`
Reasoning\n\n${m.reasoning}\n\n
`) + } + parts.push(m.content.trim() || '_(empty)_') + if (m.sources?.length) { + parts.push(m.sources.map((s) => `- [${s.title}](${s.url ?? ''})`).join('\n')) + } + return parts.join('\n\n') +} + +function renderTool(t: ToolCall, maxOutput: number, depth = 0): string { + const title = t.displayInfo?.title ?? t.name + const subtitle = t.displayInfo?.subtitle ? ` — ${t.displayInfo.subtitle}` : '' + const status = t.status === 'done' ? '' : ` (${t.status})` + const body: string[] = [] + const args = prettyJSON(t.args) + if (args && args !== '{}') body.push(codeBlock(args, 'json')) + const output = t.displayOutput ?? t.output + if (output) body.push(codeBlock(truncate(output, maxOutput))) + if (t.error) body.push(codeBlock(truncate(t.error, maxOutput))) + for (const child of t.children ?? []) body.push(renderTool(child, maxOutput, depth + 1)) + return [ + `
🔧 ${title}${subtitle}${status}`, + '', + body.join('\n\n') || '_(no output)_', + '', + '
', + ].join('\n') +} + +function renderApproval(a: Approval): string { + const chosen = a.resolvedOptionId + ? a.options?.find((o) => o.id === a.resolvedOptionId)?.label + : undefined + const outcome = a.resolved ? (chosen ?? (a.approved ? 'Allowed' : 'Denied')) : 'Pending' + return `> 🛡️ Approval — \`${a.tool_name}\`: **${outcome}**${a.is_external ? ' _(external target)_' : ''}` +} + +function renderExploring(g: ExploringGroup, maxOutput: number): string { + const steps = g.tools + .map((t) => `- ${t.displayInfo?.title ?? t.name}${t.displayInfo?.subtitle ? ` ${t.displayInfo.subtitle}` : ''}`) + .join('\n') + void maxOutput + return `
🔍 Explored ${g.tools.length} steps\n\n${steps}\n\n
` +} + +export function exportThreadMarkdown(items: ThreadItem[], opts: ExportMarkdownOptions = {}): string { + const labels = { + user: opts.labels?.user ?? 'You', + assistant: opts.labels?.assistant ?? 'Assistant', + system: opts.labels?.system ?? 'System', + } + const maxOutput = opts.maxToolOutput ?? 4000 + const out: string[] = [`# ${opts.title ?? 'Conversation'}`] + if (opts.now) out.push(`_Exported ${opts.now.toISOString()}_`) + for (const item of items) { + switch (item.kind) { + case 'message': + out.push(renderMessage(item.data, labels)) + break + case 'tool': + out.push(renderTool(item.data, maxOutput)) + break + case 'approval': + out.push(renderApproval(item.data)) + break + case 'exploring': + out.push(renderExploring(item.data, maxOutput)) + break + } + } + return out.join('\n\n') + '\n' +} diff --git a/packages/jcode-ui-core/src/index.ts b/packages/jcode-ui-core/src/index.ts index c402cea3..cfd6df35 100644 --- a/packages/jcode-ui-core/src/index.ts +++ b/packages/jcode-ui-core/src/index.ts @@ -19,3 +19,5 @@ export * from './adapters/index.js' export * from './hooks/index.js' export * from './primitives/index.js' export * from './timeline/groupExploring.js' +export * from './threads/index.js' +export * from './export/markdown.js' diff --git a/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx b/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx index 6236b1d9..26be190b 100644 --- a/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx +++ b/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx @@ -1,10 +1,17 @@ /** * ApprovalBlock — the headless approval gate. * - * Owns: the pending/resolved state split, the 3-tier decision (allow once / allow - * all / deny), the "arming" UX for "allow all" (two-step confirm to prevent - * accidental blanket approval), and dispatching via runtime actions. Does NOT - * own styling or the tool-name→icon mapping (those live in the styled wrapper). + * Owns: the pending/resolved state split, the decision contracts, the "arming" + * UX (two-step confirm for blanket approvals to prevent accidents), and + * dispatching via runtime actions. Does NOT own styling or the tool-name→icon + * mapping (those live in the styled wrapper). + * + * Two decision shapes: + * - classic boolean: allow once / allow all (armed) / deny → `resolveApproval` + * - host-defined `approval.options` (arbitrary ids, e.g. ACP + * permission_request) → `resolveApprovalOption`; if the host didn't provide + * that action, kinds fall back onto the boolean contract so mock/demo + * runtimes keep working. * * The `resolving` flag on the approval object disables controls while a resolve * request is in flight (prevents double-submit). @@ -12,22 +19,31 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Approval } from '../types/index.js' +import type { Approval, ApprovalOption } from '../types/index.js' import { useRuntimeActions } from '../runtime/context.js' +export interface ApprovalDecisionActions { + allowOnce: () => void + allowAllArm: () => void + allowAllConfirm: () => void + allowAllCancel: () => void + deny: () => void + armed: boolean + /** Options mode: choose a non-arming option (kind ≠ 'allow_always'). */ + choose: (optionId: string) => void + /** Options mode: id currently armed for two-step confirm, or null. */ + armedOptionId: string | null + /** Options mode: arm an 'allow_always' option (first click). */ + armOption: (optionId: string) => void + /** Options mode: confirm the armed option (second click). */ + confirmOption: (optionId: string) => void + /** Options mode: cancel arming. */ + cancelArm: () => void +} + export interface ApprovalBlockRenderSlots { /** Render the pending decision card. Receives the action callbacks. */ - renderPending?: ( - approval: Approval, - actions: { - allowOnce: () => void - allowAllArm: () => void - allowAllConfirm: () => void - allowAllCancel: () => void - deny: () => void - armed: boolean - }, - ) => ReactNode + renderPending?: (approval: Approval, actions: ApprovalDecisionActions) => ReactNode /** Render the resolved inline note. */ renderResolved?: (approval: Approval) => ReactNode } @@ -38,14 +54,31 @@ export interface ApprovalBlockProps extends ApprovalBlockRenderSlots { className?: string } +/** Map an option kind onto the boolean contract — the fallback used when the + * host implements only `resolveApproval`. */ +function booleanFallback( + resolve: (id: string, approved: boolean, approveAll?: boolean) => void, + approvalId: string, + option: ApprovalOption, +) { + const kind = option.kind ?? 'custom' + if (kind === 'deny') resolve(approvalId, false, false) + else resolve(approvalId, true, kind === 'allow_always') +} + export function ApprovalBlock({ approval, className, renderPending, renderResolved }: ApprovalBlockProps): ReactNode { const actions = useRuntimeActions() - // Arming state for "allow all" — the user must click twice (first arms, - // turning the button destructive; second confirms). + // Arming state — the user must click twice for blanket approvals (first + // arms, turning the button cautionary; second confirms). const [armed, setArmed] = useState(false) + const [armedOptionId, setArmedOptionId] = useState(null) if (approval.resolved) { - return
{renderResolved?.(approval) ?? }
+ return ( +
+ {renderResolved?.(approval) ?? } +
+ ) } const allowOnce = () => actions.resolveApproval(approval.id, true, false) @@ -54,50 +87,97 @@ export function ApprovalBlock({ approval, className, renderPending, renderResolv const allowAllCancel = () => setArmed(false) const deny = () => actions.resolveApproval(approval.id, false, false) + const dispatchOption = (optionId: string) => { + const opt = approval.options?.find((o) => o.id === optionId) + if (!opt) return + if (actions.resolveApprovalOption) actions.resolveApprovalOption(approval.id, optionId) + else booleanFallback(actions.resolveApproval, approval.id, opt) + } + const choose = (optionId: string) => dispatchOption(optionId) + const armOption = (optionId: string) => setArmedOptionId(optionId) + const confirmOption = (optionId: string) => { + setArmedOptionId(null) + dispatchOption(optionId) + } + const cancelArm = () => setArmedOptionId(null) + + const decisionActions: ApprovalDecisionActions = { + allowOnce, + allowAllArm, + allowAllConfirm, + allowAllCancel, + deny, + armed, + choose, + armedOptionId, + armOption, + confirmOption, + cancelArm, + } + return ( -
- {renderPending?.(approval, { allowOnce, allowAllArm, allowAllConfirm, allowAllCancel, deny, armed }) ?? - DefaultPending({ approval, allowOnce, allowAllArm, allowAllConfirm, allowAllCancel, deny, armed })} +
+ {renderPending?.(approval, decisionActions) ?? DefaultPending({ approval, actions: decisionActions })}
) } function DefaultResolved({ approval }: { approval: Approval }): ReactNode { + const optionLabel = approval.resolvedOptionId + ? approval.options?.find((o) => o.id === approval.resolvedOptionId)?.label + : undefined return ( - {approval.approved ? '✓ allowed' : '✗ denied'} · {approval.tool_name} + {optionLabel ?? (approval.approved ? '✓ allowed' : '✗ denied')} · {approval.tool_name} ) } -function DefaultPending(args: { +function DefaultPending({ + approval, + actions, +}: { approval: Approval - allowOnce: () => void - allowAllArm: () => void - allowAllConfirm: () => void - allowAllCancel: () => void - deny: () => void - armed: boolean + actions: ApprovalDecisionActions }): ReactNode { - const { approval, allowOnce, allowAllArm, allowAllConfirm, allowAllCancel, deny, armed } = args const disabled = !!approval.resolving return (
Approve {approval.tool_name}?
{approval.is_external &&
⚠ external path
} -
- - {!armed ? ( - +
+ {approval.options?.length ? ( + approval.options.map((o) => + (o.kind ?? 'custom') === 'allow_always' && actions.armedOptionId !== o.id ? ( + + ) : (o.kind ?? 'custom') === 'allow_always' ? ( + + ) : ( + + ), + ) ) : ( <> - - + + {!actions.armed ? ( + + ) : ( + <> + + + + )} + )} -
) diff --git a/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx b/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx index 63f8f266..133ac019 100644 --- a/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx +++ b/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx @@ -120,7 +120,7 @@ export function AskUserBlock({ tool, className, renderPending, renderResolved }: if (!isPending) { const answers = parseResolvedAnswers(tool) - return
{renderResolved?.(tool, answers) ?? }
+ return
{renderResolved?.(tool, answers) ?? }
} const controls: AskUserControls = { @@ -133,7 +133,7 @@ export function AskUserBlock({ tool, className, renderPending, renderResolved }: } return ( -
+
{renderPending?.(questions, controls) ?? }
) diff --git a/packages/jcode-ui-core/src/primitives/Composer.tsx b/packages/jcode-ui-core/src/primitives/Composer.tsx index 168b62d1..47698b14 100644 --- a/packages/jcode-ui-core/src/primitives/Composer.tsx +++ b/packages/jcode-ui-core/src/primitives/Composer.tsx @@ -2,19 +2,38 @@ * Composer — the headless message composer. * * Owns: textarea state, autosize, IME-safe key handling, send/queue/stop - * dispatch, and a slash-command palette skeleton. Does NOT own styling or the - * model/mode/workspace pickers (those are app-specific — the styled jcode-ui - * `ChatInput` composes this primitive and layers them on). + * dispatch, a slash-command palette skeleton, and (Composer 2) a pluggable + * attachment pipeline, drag/paste ingestion, control slots, dictation, and an + * imperative `ComposerHandle`. Does NOT own styling or the model/mode/workspace + * pickers (those are app-specific — the styled jcode-ui `ChatInput` composes + * this primitive and layers them on). * * Streaming interaction: when the runtime reports `isRunning`, the send button * becomes a stop button, and `send()` routes to `enqueueMessage` instead of * `sendMessage` (type-ahead). The runtime drains the queue on each turn end. + * + * Attachments: with no `attachmentAdapter` the legacy `allowImages` base64 path + * is used (unchanged). With an adapter, every picked/pasted/dropped file flows + * through `adapter.add`, tracked in a pending state machine (uploading → + * done/error). On send, completed image attachments still ride the ChatImage + * `images` argument (so `sendMessage` stays compatible) and the full completed + * set is also handed to `onSendAttachments`. */ -import { useCallback, useLayoutEffect, useRef, useState } from 'react' -import type { KeyboardEvent, ReactNode } from 'react' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, +} from 'react' +import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactNode } from 'react' import { useRuntimeActions, useRuntimeState } from '../runtime/context.js' import type { ChatImage } from '../types/index.js' +import { nextAttachmentId } from './attachmentAdapter.js' +import type { AttachmentAdapter, PendingAttachment } from './attachmentAdapter.js' export interface SlashCommand { /** The literal text inserted when chosen (e.g. '/goal'). */ @@ -22,6 +41,33 @@ export interface SlashCommand { description?: string } +/** Lifecycle status of a pending attachment slot. */ +export type PendingStatus = 'uploading' | 'done' | 'error' + +/** A pending-attachment row surfaced to `renderPendingAttachments`. */ +export interface PendingAttachmentItem { + /** Latest adapter snapshot (or the provisional entry while uploading). */ + attachment: PendingAttachment + status: PendingStatus + /** Remove this attachment (also invokes `adapter.remove` when present). */ + remove: () => void + /** Re-run `adapter.add` for this file (error recovery). */ + retry: () => void +} + +/** Dictation (speech-to-text) UI state passed to `renderDictationButton`. */ +export interface DictationState { + listening: boolean +} + +/** Imperative handle exposed via `ref` (used by quote / insert features). */ +export interface ComposerHandle { + /** Insert text at the caret (replacing any selection) and refocus. */ + insertText(text: string): void + /** Focus the textarea. */ + focus(): void +} + export interface ComposerRenderSlots { /** Render the slash-command dropdown when `slashState` is open. */ renderSlashMenu?: (state: SlashMenuState) => ReactNode @@ -30,15 +76,21 @@ export interface ComposerRenderSlots { /** Render the send/stop button. `mode` is 'send' or 'stop'. * Call `onActivate` on click (send when idle, stop when running). */ renderSubmitButton?: (mode: 'send' | 'stop', disabled: boolean, onActivate: () => void) => ReactNode - /** Render attached-image thumbnails (composer attachments strip). */ + /** Render attached-image thumbnails (legacy `allowImages` strip). */ renderAttachments?: (imgs: ChatImage[], remove: (i: number) => void) => ReactNode + /** Render the pending-attachment strip (adapter path). */ + renderPendingAttachments?: (items: PendingAttachmentItem[]) => ReactNode /** * Render the "add attachment" control (paperclip / +). Called with * `openPicker` that opens the hidden file input. When omitted and - * `allowImages` is true, a minimal default button is rendered. + * attachments are enabled, a minimal default button is rendered. * Mirrors assistant-ui `ComposerAddAttachment`. */ renderAddAttachment?: (openPicker: () => void) => ReactNode + /** Render an overlay while files are dragged over the composer. */ + renderDropOverlay?: () => ReactNode + /** Render the dictation (microphone) button. Only called when supported. */ + renderDictationButton?: (state: DictationState, toggle: () => void) => ReactNode /** Optional content rendered before the textarea inside the input row * (e.g. a "+" menu button). */ renderPrefix?: () => ReactNode @@ -59,6 +111,24 @@ export interface ComposerProps extends ComposerRenderSlots { acceptImages?: string /** Max image size in bytes (default 10MB). */ maxImageBytes?: number + /** + * Pluggable attachment pipeline. When provided it supersedes the legacy + * base64 image path: the picker/paste/drop route files through the adapter + * and a pending state machine tracks upload progress. + */ + attachmentAdapter?: AttachmentAdapter + /** Fired on send with the completed attachments (adapter path). */ + onSendAttachments?: (attachments: PendingAttachment[]) => void + /** Content rendered after the add-attachment control (e.g. a ModelSelector). */ + leadingControls?: ReactNode + /** Content rendered just before the submit button. */ + trailingControls?: ReactNode + /** Content rendered below the composer row. */ + footer?: ReactNode + /** Enable the dictation button (rendered only when the browser supports it). */ + enableDictation?: boolean + /** BCP-47 language tag for dictation (default: browser default). */ + dictationLang?: string /** aria-label for the textarea. */ ariaLabel?: string /** Controlled initial value (uncontrolled thereafter). */ @@ -81,6 +151,14 @@ export interface SlashMenuState { const DEFAULT_MAX_ROWS_PX = 160 +interface PendingSlot { + /** Stable react/tracking key (also used as the canonical attachment id). */ + key: string + file: File + attachment: PendingAttachment + status: PendingStatus +} + function readImageFile(file: File): Promise { return new Promise((resolve) => { if (!file.type.startsWith('image/')) { @@ -106,31 +184,82 @@ function readImageFile(file: File): Promise { }) } -export function Composer({ - placeholder = 'Send a message…', - maxRows = DEFAULT_MAX_ROWS_PX, - slashCommands, - allowImages = false, - acceptImages = 'image/*', - maxImageBytes = 10 * 1024 * 1024, - ariaLabel = 'Message input', - defaultValue = '', - className, - onSent, - renderSlashMenu, - renderQueue, - renderSubmitButton, - renderAttachments, - renderAddAttachment, - renderPrefix, - renderSuffix, -}: ComposerProps): ReactNode { +// ─── Dictation (SpeechRecognition) — minimal ambient typing, SSR-safe ───────── + +interface SpeechRecognitionResultLike { + isFinal: boolean + 0: { transcript: string } + length: number +} +interface SpeechRecognitionEventLike { + resultIndex: number + results: { length: number } & Record +} +interface SpeechRecognitionLike { + continuous: boolean + interimResults: boolean + lang: string + start(): void + stop(): void + onresult: ((e: SpeechRecognitionEventLike) => void) | null + onend: (() => void) | null + onerror: (() => void) | null +} +type SpeechRecognitionCtor = new () => SpeechRecognitionLike + +function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { + if (typeof window === 'undefined') return null + const w = window as unknown as { + SpeechRecognition?: SpeechRecognitionCtor + webkitSpeechRecognition?: SpeechRecognitionCtor + } + return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null +} + +export const Composer = forwardRef(function Composer( + { + placeholder = 'Send a message…', + maxRows = DEFAULT_MAX_ROWS_PX, + slashCommands, + allowImages = false, + acceptImages = 'image/*', + maxImageBytes = 10 * 1024 * 1024, + attachmentAdapter, + onSendAttachments, + leadingControls, + trailingControls, + footer, + enableDictation = false, + dictationLang, + ariaLabel = 'Message input', + defaultValue = '', + className, + onSent, + renderSlashMenu, + renderQueue, + renderSubmitButton, + renderAttachments, + renderPendingAttachments, + renderAddAttachment, + renderDropOverlay, + renderDictationButton, + renderPrefix, + renderSuffix, + }: ComposerProps, + ref, +): ReactNode { const actions = useRuntimeActions() const { isRunning, queued } = useRuntimeState() const [text, setText] = useState(defaultValue) const [images, setImages] = useState([]) + const [pending, setPending] = useState([]) + const [dropping, setDropping] = useState(false) + const [dictating, setDictating] = useState(false) const textareaRef = useRef(null) const fileInputRef = useRef(null) + const dragDepth = useRef(0) + + const attachmentsEnabled = !!attachmentAdapter || allowImages // --- Autosize: grow with content up to maxRows, then scroll. --- useLayoutEffect(() => { @@ -158,23 +287,235 @@ export function Composer({ textareaRef.current?.focus() }, []) + // --- Imperative handle (quote / insert). --- + const insertText = useCallback((insert: string) => { + const el = textareaRef.current + setText((prev) => { + const start = el ? el.selectionStart : prev.length + const end = el ? el.selectionEnd : prev.length + const next = prev.slice(0, start) + insert + prev.slice(end) + requestAnimationFrame(() => { + if (!el) return + el.focus() + const pos = start + insert.length + el.setSelectionRange(pos, pos) + }) + return next + }) + }, []) + + useImperativeHandle( + ref, + () => ({ + insertText, + focus: () => textareaRef.current?.focus(), + }), + [insertText], + ) + + // --- Adapter attachment pipeline (pending state machine). --- + const runAdapterAdd = useCallback( + (key: string, file: File) => { + if (!attachmentAdapter) return + attachmentAdapter + .add(file, (p) => { + setPending((prev) => + prev.map((s) => (s.key === key ? { ...s, attachment: { ...s.attachment, progress: p } } : s)), + ) + }) + .then((result) => { + setPending((prev) => + prev.map((s) => + s.key === key + ? { + ...s, + // Keep the stable key as the canonical id. + attachment: { ...result, id: key }, + status: result.error ? 'error' : 'done', + } + : s, + ), + ) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + setPending((prev) => + prev.map((s) => + s.key === key ? { ...s, attachment: { ...s.attachment, error: message }, status: 'error' } : s, + ), + ) + }) + }, + [attachmentAdapter], + ) + + const addFilesViaAdapter = useCallback( + (files: FileList | File[]) => { + if (!attachmentAdapter) return + for (const file of Array.from(files)) { + const key = nextAttachmentId() + const provisional: PendingAttachment = { + id: key, + kind: file.type.startsWith('image/') ? 'image' : 'file', + name: file.name || 'attachment', + size: file.size, + media_type: file.type || undefined, + progress: 0, + } + setPending((prev) => [...prev, { key, file, attachment: provisional, status: 'uploading' }]) + runAdapterAdd(key, file) + } + }, + [attachmentAdapter, runAdapterAdd], + ) + + const removePending = useCallback( + (key: string) => { + setPending((prev) => prev.filter((s) => s.key !== key)) + void attachmentAdapter?.remove?.(key) + }, + [attachmentAdapter], + ) + + const retryPending = useCallback( + (key: string) => { + setPending((prev) => + prev.map((s) => + s.key === key + ? { ...s, status: 'uploading', attachment: { ...s.attachment, error: undefined, progress: 0 } } + : s, + ), + ) + const slot = pending.find((s) => s.key === key) + if (slot) runAdapterAdd(key, slot.file) + }, + [pending, runAdapterAdd], + ) + + const pendingItems: PendingAttachmentItem[] = pending.map((s) => ({ + attachment: s.attachment, + status: s.status, + remove: () => removePending(s.key), + retry: () => retryPending(s.key), + })) + + // --- Legacy image attachments (no adapter). --- + const addImage = useCallback( + (img: ChatImage) => { + if (!allowImages) return + const approxBytes = (img.data.length * 3) / 4 + if (approxBytes > maxImageBytes) return + setImages((prev) => [...prev, img]) + }, + [allowImages, maxImageBytes], + ) + const addImageFiles = useCallback( + async (files: FileList | File[]) => { + if (!allowImages) return + for (const file of Array.from(files)) { + if (file.size > maxImageBytes) continue + const img = await readImageFile(file) + if (img) addImage(img) + } + }, + [addImage, allowImages, maxImageBytes], + ) + const removeImage = useCallback((i: number) => { + setImages((prev) => prev.filter((_, idx) => idx !== i)) + }, []) + + // --- Unified file ingestion (picker / paste / drop). --- + const ingestFiles = useCallback( + (files: FileList | File[]) => { + if (attachmentAdapter) addFilesViaAdapter(files) + else void addImageFiles(files) + }, + [addFilesViaAdapter, addImageFiles, attachmentAdapter], + ) + + const openPicker = useCallback(() => { + fileInputRef.current?.click() + }, []) + // --- Send / queue / stop. --- - const canSend = text.trim().length > 0 || images.length > 0 + const doneAttachments = pending.filter((s) => s.status === 'done').map((s) => s.attachment) + const canSend = + text.trim().length > 0 || images.length > 0 || doneAttachments.length > 0 const send = useCallback(() => { if (!canSend) return - const imgs = images.length > 0 ? images : undefined + const doneNow = pending.filter((s) => s.status === 'done').map((s) => s.attachment) + const attachmentImages: ChatImage[] = doneNow + .filter((a) => a.kind === 'image' && a.data) + .map((a) => ({ data: a.data as string, media_type: a.media_type || 'image/*', name: a.name })) + const allImages = [...images, ...attachmentImages] + const imgs = allImages.length > 0 ? allImages : undefined if (isRunning) { actions.enqueueMessage(text.trim(), imgs) } else { actions.sendMessage(text.trim(), imgs) } + if (doneNow.length > 0) onSendAttachments?.(doneNow) setText('') setImages([]) + setPending([]) onSent?.() - }, [actions, canSend, images, isRunning, onSent, text]) + }, [actions, canSend, images, isRunning, onSendAttachments, onSent, pending, text]) const stop = useCallback(() => actions.stop(), [actions]) + // --- Dictation. --- + const recognitionRef = useRef(null) + const dictBaseRef = useRef('') + const dictFinalRef = useRef('') + const dictationSupported = enableDictation && getSpeechRecognitionCtor() !== null + + const toggleDictation = useCallback(() => { + const Ctor = getSpeechRecognitionCtor() + if (!Ctor) return + if (recognitionRef.current) { + recognitionRef.current.stop() + return + } + const rec = new Ctor() + rec.continuous = true + rec.interimResults = true + if (dictationLang) rec.lang = dictationLang + dictFinalRef.current = '' + setText((prev) => { + dictBaseRef.current = prev ? prev.replace(/\s*$/, '') + ' ' : '' + return prev + }) + rec.onresult = (e: SpeechRecognitionEventLike) => { + let interim = '' + for (let i = e.resultIndex; i < e.results.length; i++) { + const r = e.results[i] + if (!r) continue + if (r.isFinal) dictFinalRef.current += r[0].transcript + else interim += r[0].transcript + } + setText(dictBaseRef.current + dictFinalRef.current + interim) + } + rec.onend = () => { + recognitionRef.current = null + setDictating(false) + } + rec.onerror = () => { + recognitionRef.current = null + setDictating(false) + } + recognitionRef.current = rec + setDictating(true) + rec.start() + }, [dictationLang]) + + useEffect( + () => () => { + recognitionRef.current?.stop() + recognitionRef.current = null + }, + [], + ) + // --- Key handling: Enter=send, Shift+Enter=newline, IME-safe, slash nav. --- const onKeyDown = useCallback( (e: KeyboardEvent) => { @@ -212,54 +553,109 @@ export function Composer({ [applySlash, filteredCommands, send, slashActive, slashOpen], ) - // --- Image attachments: paste + file picker + remove. - // Mirrors assistant-ui ComposerAttachments / ComposerAddAttachment, - // scoped to vision images (ChatImage base64) for agent backends. --- - const addImage = useCallback( - (img: ChatImage) => { - if (!allowImages) return - // Reject oversize by raw base64 length (~4/3 of bytes). - const approxBytes = (img.data.length * 3) / 4 - if (approxBytes > maxImageBytes) return - setImages((prev) => [...prev, img]) + const onPaste = useCallback( + (e: ClipboardEvent) => { + if (!attachmentsEnabled) return + const items = e.clipboardData?.items + if (!items) return + const files: File[] = [] + for (const it of items) { + // Adapter accepts any file kind; legacy path only images. + const wanted = attachmentAdapter ? it.kind === 'file' : it.kind === 'file' && it.type.startsWith('image/') + if (wanted) { + const file = it.getAsFile() + if (file) files.push(file) + } + } + if (files.length === 0) return + e.preventDefault() + ingestFiles(files) }, - [allowImages, maxImageBytes], + [attachmentAdapter, attachmentsEnabled, ingestFiles], ) - const addImageFiles = useCallback( - async (files: FileList | File[]) => { - if (!allowImages) return - for (const file of Array.from(files)) { - if (file.size > maxImageBytes) continue - const img = await readImageFile(file) - if (img) addImage(img) - } + + // --- Drag & drop. --- + const onDragOver = useCallback( + (e: DragEvent) => { + if (!attachmentsEnabled) return + if (!Array.from(e.dataTransfer?.types ?? []).includes('Files')) return + e.preventDefault() }, - [addImage, allowImages, maxImageBytes], + [attachmentsEnabled], + ) + const onDragEnter = useCallback( + (e: DragEvent) => { + if (!attachmentsEnabled) return + if (!Array.from(e.dataTransfer?.types ?? []).includes('Files')) return + e.preventDefault() + dragDepth.current += 1 + setDropping(true) + }, + [attachmentsEnabled], + ) + const onDragLeave = useCallback( + (e: DragEvent) => { + if (!attachmentsEnabled) return + e.preventDefault() + dragDepth.current = Math.max(0, dragDepth.current - 1) + if (dragDepth.current === 0) setDropping(false) + }, + [attachmentsEnabled], + ) + const onDrop = useCallback( + (e: DragEvent) => { + if (!attachmentsEnabled) return + e.preventDefault() + dragDepth.current = 0 + setDropping(false) + const files = e.dataTransfer?.files + if (files && files.length > 0) ingestFiles(files) + }, + [attachmentsEnabled, ingestFiles], ) - const removeImage = useCallback((i: number) => { - setImages((prev) => prev.filter((_, idx) => idx !== i)) - }, []) - const openPicker = useCallback(() => { - fileInputRef.current?.click() - }, []) const mode: 'send' | 'stop' = isRunning ? 'stop' : 'send' - const onActivate = mode === 'send' ? send : stop - const addAttachmentControl = - allowImages - ? renderAddAttachment - ? renderAddAttachment(openPicker) - : ( - - ) - : null + const pickerAccept = attachmentAdapter ? attachmentAdapter.accept ?? '*/*' : acceptImages + + const addAttachmentControl = attachmentsEnabled + ? renderAddAttachment + ? renderAddAttachment(openPicker) + : ( + + ) + : null + + const dictationButton = dictationSupported + ? renderDictationButton + ? renderDictationButton({ listening: dictating }, toggleDictation) + : ( + + ) + : null return ( -
+
+ {dropping && renderDropOverlay?.()} {renderQueue?.(queued)} {renderSlashMenu?.({ open: !!slashOpen && filteredCommands.length > 0, @@ -267,30 +663,18 @@ export function Composer({ activeIndex: slashOpen ? slashActive : -1, apply: applySlash, })} - {allowImages && images.length > 0 && renderAttachments?.(images, removeImage)} + {allowImages && !attachmentAdapter && images.length > 0 && renderAttachments?.(images, removeImage)} + {attachmentAdapter && pending.length > 0 && renderPendingAttachments?.(pendingItems)}
{renderPrefix?.()} {addAttachmentControl} + {leadingControls}