weifuwu 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # weifuwu
2
2
 
3
- **全栈框架 — 后端 HTTP 路由 + 前端 VDOM 框架 + CSS 布局系统**
3
+ **一个包的全栈框架** — 后端 HTTP + 前端 VDOM + 组件库 + CSS 设计系统。全自研、零配置、消灭样板。
4
4
 
5
5
  ```bash
6
6
  npm install weifuwu
@@ -17,21 +17,46 @@ npm install weifuwu
17
17
 
18
18
  ## 设计理念
19
19
 
20
+ ### 一句话
21
+
22
+ **weifuwu = 一个包的全栈框架:全自研、零配置、消灭样板。** 下面三条核心哲学与十条技术原则都是这句话的展开——我们不做缝合框架,每一层都自研且可预测。
23
+
24
+ ### 核心哲学
25
+
26
+ **① 一个包,全栈一体。** 后端、前端、组件、样式装在一个 npm 包里,零配置、零构建、纯 link 可用:服务端 `--import weifuwu/dev` 直接跑 `.tsx`(Node loader + esbuild 同步编译);浏览器 CDN import map 直接跑;CSS 一条 link 即得完整设计系统。
27
+
28
+ **② 全自研,诚实裁剪。** VDOM、PG v3 / RESP2 协议、GraphQL schema、OpenAI 兼容流式协议——全部自研而非包装他人。动机不是炫技而是**确定性**:自研客户端输出确定、行为可预测、错误模型统一。配套纪律是诚实裁剪:**不支持的能力明确抛 `ProtocolError('unsupported')`,绝不静默降级或"尽量支持"**(已裁剪清单见 `docs/db-clients-plan.md`)。
29
+
30
+ **③ 消灭样板。** 框架的每一层都在消灭一类样板代码:
31
+
32
+ | 样板 | 消灭方式 |
33
+ |---|---|
34
+ | 构建样板 | 动态编译(`ctx.ui.js` / `weifuwu/dev`),改代码即刷即用,零构建步骤 |
35
+ | 样式样板 | 语义原语 + 变量定制,零自定义 CSS 文件(`--wf-brand-500` 改一层值全站跟随) |
36
+ | 数据样板 | `ctx.data.get` 一个 API 覆盖 SSR 预取 / hydration 命中 / SPA fetch,写数据像写同步代码 |
37
+ | 协议样板 | 自研 PG/Redis 客户端消灭双重编码、parseRow 样板、`'EX'` 参数顺序陷阱 |
38
+
39
+ ### 技术原则(哲学的展开)
40
+
20
41
  **零运行时依赖** — 前端无 npm 运行时依赖(自研 VDOM,不引入 Virtual DOM 库、rxjs、immer 等)。后端仅依赖 `esbuild`(TSX→JS 编译)+ `graphql` + `ws`(语言/协议本身)——**数据库客户端(PostgreSQL/Redis 协议)、GraphQL schema 工具全部自研**。esbuild 作为运行时依赖随 `npm install weifuwu` 自动安装,`ctx.ui.js()` 开箱即用。
21
42
 
22
- **两阶段组件模型** — 组件 = `(initProps, ctx) => (props) => VNode`。外层函数只执行一次(mount),内层函数每次状态/props 变化时执行(render)。无 class、无 `this`、无 Hook
43
+ **两阶段组件模型** — 组件 = `(initProps, ctx) => (props) => VNode`。外层函数只执行一次(mount),内层函数每次状态/props 变化时执行(render)。无 class、无 `this`、无 Hook——**位置即语义**:外层天生只跑一次,没有 hooks 规则、没有依赖数组、没有闭包陷阱(详解见[核心概念](#核心概念))。
23
44
 
24
- **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发当前组件的 VDOM patch。也支持手动 `ctx.ui.render()` 精确控制渲染时机。无需手动调用 `useState`/`useEffect`。
45
+ **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发当前组件的 VDOM patch;也支持手动 `ctx.ui.render()` 精确控制渲染时机。**组件库手动优先、业务层自动优先**——同一框架内按角色选模式(详见[组件库](#组件库-weifuwucomponents))。
25
46
 
26
- **中间件注入一切** — 后端和前端共用同一理念:中间件向 `ctx` 注入能力(`ctx.sql` / `ctx.redis` / `ctx.api` / `ctx.auth` / `ctx.i18n` 等),Handler/组件从 `ctx` 读取。
47
+ **中间件注入一切** — 后端和前端共用同一理念:中间件向 `ctx` 注入能力(`ctx.sql` / `ctx.redis` / `ctx.api` / `ctx.auth` / `ctx.i18n` / `ctx.limit` / `ctx.email` / `ctx.queue` / `ctx.ai` 等),Handler/组件从 `ctx` 读取。
27
48
 
28
- **自研数据层** — `ctx.sql`(PG v3 协议)与 `ctx.redis`(RESP2 协议)为**自研客户端**:确定性输出、行为可预测、统一错误模型。jsonb 自动解码、TTL 安全 API、schema 写前校验——高频痛点(双重编码/parseRow 样板/`'EX'` 参数顺序)从根上消除。
49
+ **async 工厂组件** — `async (ctx) => (initProps, ctx) => (props) => VNode`:工厂层声明数据(`await ctx.data.get`)、mount 初始化状态(`$`)、render 输出视图。异步只在工厂边界,mount/render 保持同步;数据经闭包注入,写数据像写同步代码。三条纪律见[核心概念 · async 组件](#核心概念)。
50
+
51
+ **SPA/SSR/Hydration 统一透明** — 同一份路由定义(`routes`)一个组件三场景自动适配:后端 `uiSsr({ routes })` 匹配即自动 SSR(完整 HTML + `__DATA__`),客户端 `router({ routes })` + `RouteView` + `mount(..., { hydrate: true })` 按 URL 同源匹配并收养服务端 HTML(不重建、无闪跳)。`ctx.data.get` 一个 API:SSR 预取 / hydration 命中(不重复请求)/ SPA 触发 fetch。服务端直接用 `.tsx`(`weifuwu/dev` Node loader),前后端同一 JSX 运行时。
29
52
 
30
- **SSR + 动态编译** 后端 `ctx.ui.js()` esbuild 实时编译 TSX,开发时改代码即刷即用,零构建步骤。
53
+ **AI 是一等公民** — 自研 OpenAI 兼容协议(`docs/ai-contract.md`)+ 零依赖流式客户端 + agent 工具循环 + HITL 人工审批。后端 `ctx.ai` 一个入口:`chat()` / `stream()` / `agent()` / `approve()`;前端 `ctx.ui.useChat()`(会话语义)+ `AiChat` 组件(标准对话界面)——流式 token / 工具调用卡 / 审批卡开箱即用,协议对页面完全透明,不用 ai-sdk。
31
54
 
32
- **async 工厂组件**`async (ctx) => (initProps, ctx) => (props) => VNode`:工厂层声明数据(`await ctx.data.get`)、mount 初始化状态(`$`)、render 输出视图。异步只在工厂边界,mount/render 保持同步;数据经闭包注入,写数据像写同步代码。
55
+ **SaaS 地基随包内置**rateLimit(限流)/ email(邮件)/ userSystem(用户认证)/ queue(可靠队列)以中间件形态随包提供,`app.use(...)` 一行接入(详见文末[SaaS 地基模块](#saas-地基模块ratelimit--email--usersystem--queue))。
33
56
 
34
- **SPA/SSR/Hydration 统一透明**同一份路由定义(`routes`)一个组件形态三场景自动适配:后端 `uiSsr({ routes })` 匹配即自动 SSR(完整 HTML + `__DATA__`),客户端 `router({ routes })` + `RouteView` + `mount(..., { hydrate: true })` 按 URL 同源匹配并收养服务端 HTML(不重建、无闪跳)。`ctx.data.get` 一个 API:SSR 预取 / hydration 命中(不重复请求)/ SPA 触发 fetch。服务端直接用 `.tsx`(`weifuwu/dev` Node loader),前后端同一 JSX 运行时。
57
+ **零自定义 CSS 设计系统** 一个 CSS 文件 = 双层 Token + 布局原语 + 工具类 + 组件样式。业务页面不写 style.css:组件 + `wf-*` 原语写业务,品牌/组件定制改变量(`--wf-brand-500` / `--wf-btn-radius`),暗色自动(详见[布局系统](#布局系统-weifuwulayout))。
58
+
59
+ **自研数据层** — `ctx.sql`(PG v3 协议)与 `ctx.redis`(RESP2 协议)为**自研客户端**:确定性输出、行为可预测、统一错误模型。jsonb 自动解码、TTL 安全 API、schema 写前校验——高频痛点(双重编码/parseRow 样板/`'EX'` 参数顺序)从根上消除。
35
60
 
36
61
  ---
37
62
 
@@ -211,7 +236,7 @@ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
211
236
  | 资源 | CDN 地址 | 说明 |
212
237
  |------|---------|------|
213
238
  | `weifuwu/client` | `https://unpkg.com/weifuwu@latest/dist/client/index.js` | 客户端核心(createApp, h, 路由, 状态管理等) |
214
- | `weifuwu/components` | `https://unpkg.com/weifuwu@latest/dist/components/index.js` | 44 个 UI 组件(Button, Card, Table, Modal 等) |
239
+ | `weifuwu/components` | `https://unpkg.com/weifuwu@latest/dist/components/index.js` | 46 个 UI 组件(Button, Card, Table, Modal 等) |
215
240
  | 组件样式 | `https://unpkg.com/weifuwu@latest/dist/components/style.css` | 组件 CSS + 115 个主题 Token + 67 个布局原语 |
216
241
  | 独立布局系统 | `https://unpkg.com/weifuwu@latest/dist/layout/weifuwu-layout.css` | 仅 CSS 布局,不依赖 JS |
217
242
 
@@ -234,7 +259,7 @@ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
234
259
  | `weifuwu` | **email** | 邮件发送(Resend/SMTP 自研/自定义适配器)→ `ctx.email` | Router |
235
260
  | `weifuwu` | **userSystem** | 用户系统(scrypt 密码哈希 + 混合会话)→ `ctx.user` / `ctx.auth` + `/api/auth/*` | Router, postgres |
236
261
  | `weifuwu` | **queue** | 可靠任务队列(Redis Streams,at-least-once + DLQ)→ `ctx.queue` | Router, redis |
237
- | `weifuwu` | **ai** | LLM 对话(自研 OpenAI 兼容协议 + 自研 SSE 解码,默认 DeepSeek)→ `ctx.ai` + `aiStream` | Router |
262
+ | `weifuwu` | **ai** | LLM 对话(自研 OpenAI 兼容协议 + 自研 SSE 解码,默认 DeepSeek)→ `ctx.ai` + `ctx.ui.useChat` + `AiChat` | Router |
238
263
  | `weifuwu/dev` | **dev loader** | Node loader:服务端直接跑 `.ts/.tsx`(`--import weifuwu/dev`) | esbuild |
239
264
  | `weifuwu` | **graphql** | GraphQL 端点(支持 GraphiQL) | Router |
240
265
  | `weifuwu` | **createMiddleware** | 类型安全中间件工厂 | — |
@@ -243,14 +268,14 @@ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
243
268
  | Router 方法 | **app.graphql()** | GraphQL 端点(支持 GraphiQL),Router 实例方法(无需单独 import) | Router |
244
269
  | `weifuwu/client` | **createApp** | 应用引导 + VDOM 渲染引擎 | — |
245
270
  | `weifuwu/client` | **router / RouteView** | 前端路由(history/hash 模式) | createApp |
246
- | `weifuwu/client` | **asyncComponent** | async 工厂组件(形态 C):工厂层声明数据,mount/render 同步 | — |
271
+ | `weifuwu/client` | **asyncComponent** | async 工厂组件:工厂层声明数据,mount/render 同步(三条纪律见[核心概念](#核心概念)) | — |
247
272
  | `weifuwu/client` | **ctx.data** | 数据管道:SSR 预取 / hydration 命中 / SPA fetch(`ctx.data.get`) | createApp |
248
273
  | `weifuwu/client` | **api / auth / ws** | HTTP 客户端 / 认证 / WebSocket 中间件 | createApp |
249
274
  | `weifuwu/client` | **i18n** | 国际化中间件(运行时切换语言) | createApp |
250
275
  | `weifuwu/client` | **ErrorBoundary** | 错误边界组件 | createApp |
251
276
  | `weifuwu/client` | **lockScroll/trapFocus** | 滚动锁定 / 焦点陷阱工具 | — |
252
277
  | `weifuwu/client` | **popup** | 弹层 fixed 定位工具(`computeFixedPos` / `computeFixedPosRect`) | — |
253
- | `weifuwu/components` | **44 个组件** | Button/Table/Modal/Confirm/Toast/... + `confirm()` / `toast()` 命令式中间件 | weifuwu/client |
278
+ | `weifuwu/components` | **47 个组件** | Button/Table/Modal/Confirm/Toast/... + `confirm()` / `toast()` 命令式中间件 | weifuwu/client |
254
279
  | `weifuwu/layout` | **CSS 布局** | 67 个布局原语 + 115 个主题 Token(也支持 `weifuwu/layout/style.css`) | — |
255
280
 
256
281
  ---
@@ -298,6 +323,49 @@ const Counter = (_init, ctx) => {
298
323
  | 读取 | handler 读取 ctx | 组件读取 ctx |
299
324
  | 渲染 | 返回 Response | `ctx.ui.render()` / `ctx.ui.dirty()` / `$.x = val` 触发局部 VDOM patch |
300
325
 
326
+ ### async 组件(三条纪律)
327
+
328
+ `asyncComponent` 让"拿数据渲染页面"像写同步代码——三层结构:**工厂**(async,只执行一次,声明数据)→ **mount**(初始化 `$`)→ **render**(输出视图)。异步只在工厂边界:
329
+
330
+ ```tsx
331
+ const UserProfile = asyncComponent(async (ctx) => {
332
+ const user = await ctx.data.get(`/api/user/${ctx.params.id}`) // ① 工厂层:声明数据
333
+ return (_init, ctx) => {
334
+ const $ = ctx.ui.$()
335
+ $.liked = false // ② mount:客户端状态
336
+ return (props) =>
337
+ h('div', {},
338
+ h('p', {}, user.name), // 服务端状态(闭包,SSR 进 HTML)
339
+ h('button', { onClick: () => $.liked = !$.liked }, $.liked ? '❤️' : '🤍'))
340
+ }
341
+ })
342
+ ```
343
+
344
+ **三条纪律**(不遵守就是隐性 bug):
345
+
346
+ | 纪律 | 反例 | 正确 |
347
+ |---|---|---|
348
+ | ① 数据 key 必须含维度 | `ctx.data.get('/api/user')`——`/users/1 → /users/2` 导航命中旧缓存 | `ctx.data.get(\`/api/user/${ctx.params.id}\`)` |
349
+ | ② 会变的数据放 `$` | `const count = data.count`——点击永不更新 | `$.count = data.count`(初始值 seed 自服务端数据) |
350
+ | ③ 初始状态必须确定性 | `$.w = window.innerWidth`——SSR/hydration mismatch | 用服务端数据 seed,交互后再测 |
351
+
352
+ **常见坑**:
353
+ - 工厂**拿不到 props**——数据维度从 `ctx.params` / `ctx.data` 取
354
+ - 闭包数据是页面加载时的**快照**——路由参数变化靠工厂重跑刷新(key 变 → 缓存 miss → 重新取数);工厂缓存绑定页面上下文,路由导航/登录登出时自动失效
355
+ - **个性化数据不进 `ctx.data`**——SSR 会把工厂取数结果序列化给所有客户端,会话/用户相关数据留在 `$` + fetch
356
+
357
+ ### 渲染策略:SPA 还是 SSR?
358
+
359
+ 组件与路由的写法**完全一样**,差异只有入口两行:
360
+
361
+ | | SPA | SSR + Hydration |
362
+ |---|---|---|
363
+ | 适用 | 应用页(后台、工具、Dashboard) | 内容页(博客、营销,需要 SEO/首屏) |
364
+ | 后端 | HTML 外壳 | `uiSsr({ routes })` 一行(自动完整 HTML + `__DATA__`) |
365
+ | 客户端 | `mount('#root', RouteView)` | `mount('#root', RouteView, { hydrate: true })` |
366
+
367
+ **怎么选**:默认 SPA;需要 SEO 或首屏即内容时用 SSR。两种模式可混合——一个 app 内 `uiSsr` 匹配共享路由,未匹配 `next()` 走普通 handler。
368
+
301
369
  ### Closeable 接口
302
370
 
303
371
  所有有状态模块(postgres、redis)实现 `close(): Promise<void>`,serve 关闭时自动调用。
@@ -1651,7 +1719,7 @@ const UserProfile: Component = (initProps, ctx) => {
1651
1719
  }
1652
1720
  ```
1653
1721
 
1654
- ### asyncComponent 工厂(形态 C)— 同步式数据声明
1722
+ ### asyncComponent 工厂(async 组件)— 同步式数据声明
1655
1723
 
1656
1724
  `async (ctx) => (initProps, ctx) => (props) => VNode` — 工厂层(async,只执行一次并缓存)声明数据/加载代码,mount/render 保持同步。数据经闭包注入组件,渲染无 loading 分支:
1657
1725
 
@@ -2148,7 +2216,7 @@ import type { RouterOptions } from 'weifuwu/client'
2148
2216
 
2149
2217
  # 组件库 (`weifuwu/components`)
2150
2218
 
2151
- 44 个 HTML 原语组件。每个是 `(_init, ctx) => (props) => VNode`(两阶段组件,与前端框架同一模型),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
2219
+ 46 个 HTML 原语组件。每个是 `(_init, ctx) => (props) => VNode`(两阶段组件,与前端框架同一模型),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
2152
2220
 
2153
2221
  ```ts
2154
2222
  import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
@@ -2368,6 +2436,7 @@ props 变化 ──────────────────────
2368
2436
 
2369
2437
  | 组件 | 导入名 | 关键 Props | 说明 |
2370
2438
  |-----|--------|-----------|------|
2439
+ | AiChat | `AiChat` | `chat`, `maxHeight?`, `labels?`, `renderMessage?`, `renderToolArgs?` | 标准 AI 对话界面:气泡 + 工具卡 + 审批卡 + 自动滚动 + 错误重试(接收 `ctx.ui.useChat()` handle) |
2371
2440
  | ToolCallCard | `ToolCallCard` | `call`, `progress?`, `result?`, `renderArgs?` | 工具调用卡片:running(进度条)/ ok / error 三态(协议 §4) |
2372
2441
  | ApprovalCard | `ApprovalCard` | `request`, `status?`, `onApprove`, `onReject` | 人工审批卡片:待批(允许/拒绝+备注)/ 已批 / 已拒 / 超时(协议 §4.5) |
2373
2442
 
@@ -2403,7 +2472,6 @@ app.get('/layout.css', (req, ctx) => ctx.ui.css('weifuwu/layout'))
2403
2472
  ```
2404
2473
 
2405
2474
  也支持相对路径:`ctx.ui.css('./src/style.css')`。
2406
- ```
2407
2475
 
2408
2476
  ## 67 个布局原语
2409
2477
 
@@ -2998,6 +3066,22 @@ const handle = aiStream('/api/chat', { messages }, {
2998
3066
  handle.abort() // 用户停止/组件卸载/导航跳走
2999
3067
  ```
3000
3068
 
3069
+ 前端对话层(会话语义 + 标准界面,协议对页面透明):
3070
+
3071
+ ```tsx
3072
+ // ctx.ui.useChat:会话语义(消息累积/工具内嵌/审批/重试),返回页面同一个 $
3073
+ const $ = ctx.ui.useChat({ url: '/api/chat', approveUrl: '/api/approve' })
3074
+ // $.messages / $.input / $.streaming / $.error / $.usage / $.step
3075
+ // $.send() / $.stop() / $.retry() / $.clear() / $.approve('approved', note?)
3076
+
3077
+ // AiChat:标准对话界面(气泡 / 工具卡 / 审批卡 / 自动滚动 / 错误重试)
3078
+ return () => <AiChat chat={$} />
3079
+
3080
+ // agent 模式消息内嵌:msg.toolCalls(ToolCallCard 直接消费)/ msg.approval(ApprovalCard)
3081
+ ```
3082
+
3083
+ > 分层:`ctx.ai`(后端协议)→ `aiStream`(传输解码)→ `useChat`(会话语义)→ `AiChat`(标准界面)。要完全自定义 UI 的应用用 useChat + 自有渲染;要 5 分钟出界面用 AiChat。
3084
+
3001
3085
  - **协议**:`wf:` 命名空间(message_start/token/tool_call/tool_progress/usage/done/error + agent 扩展 step/approval_request),SSE 下行 + POST 上行,错误即值、未知事件透传、`x:*` 自定义事件(详见 [docs/ai-contract.md](./docs/ai-contract.md))
3002
3086
  - **agent 引擎**:`a.agent({ systemPrompt, tools, humanInTheLoop })` 工具循环(LLM → tool_call → 执行 → 回喂 → 重复);工具可 `emit` 进度/自定义事件、接收 `signal` 取消;HITL 审批(`ctx.ai.approve` 响应,拒绝≠终止、modified 改参、超时兜底)
3003
3087
  - **零依赖**:自研 OpenAI 兼容客户端(fetch + SSE 解析),默认 DeepSeek,`baseUrl` 可换任意 OpenAI 兼容端点(Ollama/vLLM/Moonshot…)
@@ -33,6 +33,8 @@ export { extendCtx } from './types.ts';
33
33
  export type { WfuiContext, AppMiddleware, RouteDef } from './types.ts';
34
34
  export { aiStream } from './ai.ts';
35
35
  export type { AiStreamCallbacks, AiStreamOptions, AiStreamHandle } from './ai.ts';
36
+ export { toChatMessages } from './use-chat.ts';
37
+ export type { UiMessage, UiToolCall, UseChatOptions, UseChatState, UseChatHandle, ChatApi } from './use-chat.ts';
36
38
  export type { WfStreamEvent, WfMessageStart, WfToken, WfUsage, WfDone, WfError, WfErrorCode, WfToolCall, WfToolResult, WfToolProgress, WfStep, WfApprovalRequest, WfApprovalResponse, WfApprovalDecision, ChatMessage, ChatParams, MessageRole, ToolCall, ToolDefinition, } from '../ai/types.ts';
37
39
  export { ErrorBoundary } from './error-boundary.ts';
38
40
  export type { ErrorBoundaryProps } from './error-boundary.ts';
@@ -838,6 +838,7 @@ async function hydrateVNode(container, vnode, ctx) {
838
838
  // src/client/reactive.ts
839
839
  function createReactiveState(dirty) {
840
840
  const proxyCache = /* @__PURE__ */ new WeakMap();
841
+ const watchers = /* @__PURE__ */ new Set();
841
842
  const reactive = (target) => {
842
843
  if (target === null || typeof target !== "object") return target;
843
844
  if (proxyCache.has(target)) return proxyCache.get(target);
@@ -847,6 +848,7 @@ function createReactiveState(dirty) {
847
848
  if (old === value) return true;
848
849
  Reflect.set(target2, key, value);
849
850
  dirty();
851
+ for (const w of watchers) w();
850
852
  return true;
851
853
  },
852
854
  get(target2, key) {
@@ -858,6 +860,7 @@ function createReactiveState(dirty) {
858
860
  if (Reflect.has(target2, key)) {
859
861
  Reflect.deleteProperty(target2, key);
860
862
  dirty();
863
+ for (const w of watchers) w();
861
864
  }
862
865
  return true;
863
866
  }
@@ -865,7 +868,326 @@ function createReactiveState(dirty) {
865
868
  proxyCache.set(target, proxy);
866
869
  return proxy;
867
870
  };
868
- return reactive({});
871
+ const root = reactive({});
872
+ Object.defineProperty(root, "__watch", {
873
+ value: (cb) => {
874
+ watchers.add(cb);
875
+ return () => {
876
+ watchers.delete(cb);
877
+ };
878
+ },
879
+ writable: false,
880
+ enumerable: false
881
+ });
882
+ return root;
883
+ }
884
+
885
+ // src/client/ai.ts
886
+ var RECORD_LIMIT = 1e3;
887
+ function aiStream(url, body, options) {
888
+ const controller = new AbortController();
889
+ const external = options?.signal;
890
+ if (external) {
891
+ if (external.aborted) controller.abort();
892
+ else external.addEventListener("abort", () => controller.abort(), { once: true });
893
+ }
894
+ const traceId = options?.traceId ?? randomId();
895
+ const record = options?.record !== false;
896
+ const events = [];
897
+ const done = (async () => {
898
+ let res;
899
+ try {
900
+ res = await fetch(url, {
901
+ method: "POST",
902
+ headers: {
903
+ "Content-Type": "application/json",
904
+ "X-Trace-Id": traceId,
905
+ Accept: "text/event-stream",
906
+ ...options?.headers
907
+ },
908
+ body: JSON.stringify(body),
909
+ signal: controller.signal
910
+ });
911
+ } catch (err) {
912
+ if (controller.signal.aborted) return;
913
+ options?.onError?.({ code: "provider_error", message: err instanceof Error ? err.message : String(err) });
914
+ return;
915
+ }
916
+ if (!res.ok) {
917
+ options?.onError?.({
918
+ code: httpErrorCode(res.status),
919
+ message: `HTTP ${res.status} ${res.statusText}`
920
+ });
921
+ return;
922
+ }
923
+ try {
924
+ for await (const { name, data } of parseWfEvents(res.body)) {
925
+ if (controller.signal.aborted) return;
926
+ pushEvent(events, { name, data }, record);
927
+ dispatch(name, data, options ?? {});
928
+ }
929
+ } catch (err) {
930
+ if (controller.signal.aborted) return;
931
+ options?.onError?.({ code: "provider_error", message: err instanceof Error ? err.message : String(err) });
932
+ }
933
+ })();
934
+ return {
935
+ abort: () => controller.abort(),
936
+ done,
937
+ traceId,
938
+ events
939
+ };
940
+ }
941
+ function dispatch(name, data, o) {
942
+ switch (name) {
943
+ case "wf:token":
944
+ return o.onToken?.(data.text);
945
+ case "wf:tool_call":
946
+ return o.onToolCall?.(data);
947
+ case "wf:tool_result":
948
+ return o.onToolResult?.(data);
949
+ case "wf:tool_progress":
950
+ return o.onToolProgress?.(data);
951
+ case "wf:step":
952
+ return o.onStep?.(data);
953
+ case "wf:approval_request":
954
+ return o.onApproval?.(data);
955
+ case "wf:usage":
956
+ return o.onUsage?.(data);
957
+ case "wf:done":
958
+ return o.onDone?.(data);
959
+ case "wf:error":
960
+ return o.onError?.(data);
961
+ default:
962
+ return o.onEvent?.(name, data);
963
+ }
964
+ }
965
+ async function* parseWfEvents(stream) {
966
+ const reader = stream.getReader();
967
+ const decoder = new TextDecoder();
968
+ let buffer = "";
969
+ try {
970
+ while (true) {
971
+ const { done, value } = await reader.read();
972
+ if (done) break;
973
+ buffer += decoder.decode(value, { stream: true });
974
+ const blocks = buffer.split("\n\n");
975
+ buffer = blocks.pop() ?? "";
976
+ for (const block of blocks) {
977
+ let name = "message";
978
+ let data = "";
979
+ for (const line of block.split("\n")) {
980
+ if (line.startsWith("event: ")) name = line.slice(7);
981
+ else if (line.startsWith("data: ")) data += line.slice(6);
982
+ }
983
+ if (!data) continue;
984
+ try {
985
+ yield { name, data: JSON.parse(data) };
986
+ } catch {
987
+ }
988
+ }
989
+ }
990
+ } finally {
991
+ reader.releaseLock();
992
+ }
993
+ }
994
+ function pushEvent(events, e, record) {
995
+ if (!record) return;
996
+ if (events.length >= RECORD_LIMIT) events.shift();
997
+ events.push(e);
998
+ }
999
+ function httpErrorCode(status) {
1000
+ if (status === 401 || status === 403) return "auth_failed";
1001
+ if (status === 429) return "rate_limited";
1002
+ if (status >= 500) return "provider_error";
1003
+ return "invalid_request";
1004
+ }
1005
+ function randomId() {
1006
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1007
+ return crypto.randomUUID();
1008
+ }
1009
+ return `trace-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1010
+ }
1011
+
1012
+ // src/client/use-chat.ts
1013
+ function toChatMessages(msgs) {
1014
+ return msgs.map((m) => ({ role: m.role, content: m.content }));
1015
+ }
1016
+ function uid() {
1017
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1018
+ return crypto.randomUUID();
1019
+ }
1020
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1021
+ }
1022
+ function createChatSession(state, transport, options) {
1023
+ if (options.initialMessages) {
1024
+ state.messages = structuredClone(options.initialMessages);
1025
+ } else if (state.messages === void 0) state.messages = [];
1026
+ if (state.input === void 0) state.input = "";
1027
+ if (state.streaming === void 0) state.streaming = false;
1028
+ if (state.error === void 0) state.error = null;
1029
+ if (state.usage === void 0) state.usage = null;
1030
+ if (state.step === void 0) state.step = null;
1031
+ let handle = null;
1032
+ function current() {
1033
+ if (!state.streaming) return void 0;
1034
+ const m = state.messages[state.messages.length - 1];
1035
+ return m && m.role === "assistant" && m.status === "streaming" ? m : void 0;
1036
+ }
1037
+ function apply(name, data, onEvent) {
1038
+ const m = current();
1039
+ switch (name) {
1040
+ case "wf:token":
1041
+ if (m) m.content += data.text;
1042
+ break;
1043
+ case "wf:tool_call": {
1044
+ if (m) {
1045
+ if (!m.toolCalls) m.toolCalls = [];
1046
+ m.toolCalls.push({ call: data, status: "running" });
1047
+ }
1048
+ break;
1049
+ }
1050
+ case "wf:tool_progress": {
1051
+ const p = data;
1052
+ const tc = m?.toolCalls?.find((t) => t.call.id === p.toolCallId);
1053
+ if (tc) tc.progress = p;
1054
+ break;
1055
+ }
1056
+ case "wf:tool_result": {
1057
+ const r = data;
1058
+ const tc = m?.toolCalls?.find((t) => t.call.id === r.id);
1059
+ if (tc) {
1060
+ tc.result = r;
1061
+ tc.status = r.ok ? "ok" : "error";
1062
+ }
1063
+ break;
1064
+ }
1065
+ case "wf:approval_request":
1066
+ if (m) m.approval = data;
1067
+ break;
1068
+ case "wf:usage":
1069
+ if (m) m.usage = data;
1070
+ break;
1071
+ case "wf:step":
1072
+ state.step = data;
1073
+ break;
1074
+ case "wf:done":
1075
+ if (m) {
1076
+ m.status = "done";
1077
+ m.approval = void 0;
1078
+ }
1079
+ state.streaming = false;
1080
+ state.step = null;
1081
+ break;
1082
+ case "wf:error":
1083
+ if (m) {
1084
+ m.status = "error";
1085
+ m.error = data;
1086
+ m.approval = void 0;
1087
+ }
1088
+ state.error = data;
1089
+ state.streaming = false;
1090
+ state.step = null;
1091
+ break;
1092
+ default:
1093
+ onEvent?.(name, data);
1094
+ break;
1095
+ }
1096
+ }
1097
+ function startStream() {
1098
+ const body = options.body ? options.body(state.messages) : { messages: toChatMessages(state.messages) };
1099
+ handle = transport(options.url, body, {
1100
+ onToken: (t) => apply("wf:token", { text: t }),
1101
+ onToolCall: (c) => apply("wf:tool_call", c),
1102
+ onToolProgress: (p) => apply("wf:tool_progress", p),
1103
+ onToolResult: (r) => apply("wf:tool_result", r),
1104
+ onApproval: (req) => apply("wf:approval_request", req),
1105
+ onUsage: (u) => apply("wf:usage", u),
1106
+ onStep: (s) => apply("wf:step", s),
1107
+ onDone: (d) => apply("wf:done", d),
1108
+ onError: (e) => apply("wf:error", e),
1109
+ onEvent: (n, d) => apply(n, d, options.onEvent)
1110
+ }, { signal: options.signal, headers: options.headers });
1111
+ state.streaming = true;
1112
+ const m = current();
1113
+ if (m && handle?.traceId) m.id = handle.traceId;
1114
+ }
1115
+ function send() {
1116
+ if (state.streaming) return;
1117
+ const text = String(state.input ?? "").trim();
1118
+ if (!text) return;
1119
+ state.input = "";
1120
+ state.error = null;
1121
+ state.usage = null;
1122
+ state.step = null;
1123
+ state.messages.push(
1124
+ { id: uid(), role: "user", content: text, status: "done" },
1125
+ { id: uid(), role: "assistant", content: "", status: "streaming", toolCalls: [] }
1126
+ );
1127
+ startStream();
1128
+ }
1129
+ function stop() {
1130
+ handle?.abort();
1131
+ state.streaming = false;
1132
+ state.step = null;
1133
+ const m = state.messages[state.messages.length - 1];
1134
+ if (m && m.role === "assistant" && m.status === "streaming") {
1135
+ if (!m.content && (!m.toolCalls || m.toolCalls.length === 0)) {
1136
+ state.messages.pop();
1137
+ } else {
1138
+ m.status = "done";
1139
+ }
1140
+ }
1141
+ }
1142
+ function retry() {
1143
+ if (state.streaming) return;
1144
+ let idx = -1;
1145
+ for (let i = state.messages.length - 1; i >= 0; i--) {
1146
+ if (state.messages[i].role === "user") {
1147
+ idx = i;
1148
+ break;
1149
+ }
1150
+ }
1151
+ if (idx === -1) return;
1152
+ state.messages.splice(idx + 1);
1153
+ state.error = null;
1154
+ state.usage = null;
1155
+ state.step = null;
1156
+ state.messages.push({ id: uid(), role: "assistant", content: "", status: "streaming", toolCalls: [] });
1157
+ startStream();
1158
+ }
1159
+ function clear() {
1160
+ handle?.abort();
1161
+ state.messages = [];
1162
+ state.input = "";
1163
+ state.streaming = false;
1164
+ state.error = null;
1165
+ state.usage = null;
1166
+ state.step = null;
1167
+ }
1168
+ async function approve(decision, note) {
1169
+ const m = state.messages[state.messages.length - 1];
1170
+ const req = m?.role === "assistant" ? m.approval : void 0;
1171
+ if (!req) return;
1172
+ m.approval = void 0;
1173
+ if (!options.approveUrl) return;
1174
+ try {
1175
+ await fetch(options.approveUrl, {
1176
+ method: "POST",
1177
+ headers: { "Content-Type": "application/json", ...options.headers },
1178
+ body: JSON.stringify({ id: req.id, decision, note }),
1179
+ signal: options.signal
1180
+ });
1181
+ } catch (err) {
1182
+ if (options.signal?.aborted) return;
1183
+ state.error = { code: "provider_error", message: err instanceof Error ? err.message : String(err) };
1184
+ }
1185
+ }
1186
+ function dispose() {
1187
+ handle?.abort();
1188
+ handle = null;
1189
+ }
1190
+ return { send, stop, retry, clear, approve, dispose };
869
1191
  }
870
1192
 
871
1193
  // src/client/app.ts
@@ -1050,6 +1372,31 @@ function createApp() {
1050
1372
  }
1051
1373
  return this._$cache;
1052
1374
  },
1375
+ /**
1376
+ * AI 对话会话(会话语义 + 工具调用内嵌 + HITL 审批)
1377
+ *
1378
+ * 用法(mount 阶段):
1379
+ * const $ = ctx.ui.useChat({ url: '/api/chat', approveUrl: '/api/approve' })
1380
+ * // 状态:$.messages / $.input / $.streaming / $.error / $.usage / $.step
1381
+ * // 操作:$.send() / $.stop() / $.retry() / $.clear() / $.approve('approved', note?)
1382
+ * // agent:msg.toolCalls(ToolCallCard 直接消费) / msg.approval(ApprovalCard)
1383
+ *
1384
+ * 返回组件同一个 $(WeakMap 缓存复用):chat 状态与页面状态共处一个容器。
1385
+ * 卸载时调用 $.dispose()(或经 ref cleanup)中止流,防泄漏。
1386
+ */
1387
+ useChat: function(options2) {
1388
+ const state = this.$();
1389
+ const api2 = createChatSession(state, aiStream, options2);
1390
+ Object.assign(state, {
1391
+ send: api2.send,
1392
+ stop: api2.stop,
1393
+ retry: api2.retry,
1394
+ clear: api2.clear,
1395
+ approve: api2.approve,
1396
+ dispose: api2.dispose
1397
+ });
1398
+ return state;
1399
+ },
1053
1400
  /**
1054
1401
  * 响应式媒体查询:注册监听,值变化时自动 dirty
1055
1402
  *
@@ -1584,133 +1931,6 @@ function auth(options) {
1584
1931
  };
1585
1932
  }
1586
1933
 
1587
- // src/client/ai.ts
1588
- var RECORD_LIMIT = 1e3;
1589
- function aiStream(url, body, options) {
1590
- const controller = new AbortController();
1591
- const external = options?.signal;
1592
- if (external) {
1593
- if (external.aborted) controller.abort();
1594
- else external.addEventListener("abort", () => controller.abort(), { once: true });
1595
- }
1596
- const traceId = options?.traceId ?? randomId();
1597
- const record = options?.record !== false;
1598
- const events = [];
1599
- const done = (async () => {
1600
- let res;
1601
- try {
1602
- res = await fetch(url, {
1603
- method: "POST",
1604
- headers: {
1605
- "Content-Type": "application/json",
1606
- "X-Trace-Id": traceId,
1607
- Accept: "text/event-stream",
1608
- ...options?.headers
1609
- },
1610
- body: JSON.stringify(body),
1611
- signal: controller.signal
1612
- });
1613
- } catch (err) {
1614
- if (controller.signal.aborted) return;
1615
- options?.onError?.({ code: "provider_error", message: err instanceof Error ? err.message : String(err) });
1616
- return;
1617
- }
1618
- if (!res.ok) {
1619
- options?.onError?.({
1620
- code: httpErrorCode(res.status),
1621
- message: `HTTP ${res.status} ${res.statusText}`
1622
- });
1623
- return;
1624
- }
1625
- try {
1626
- for await (const { name, data } of parseWfEvents(res.body)) {
1627
- if (controller.signal.aborted) return;
1628
- pushEvent(events, { name, data }, record);
1629
- dispatch(name, data, options ?? {});
1630
- }
1631
- } catch (err) {
1632
- if (controller.signal.aborted) return;
1633
- options?.onError?.({ code: "provider_error", message: err instanceof Error ? err.message : String(err) });
1634
- }
1635
- })();
1636
- return {
1637
- abort: () => controller.abort(),
1638
- done,
1639
- traceId,
1640
- events
1641
- };
1642
- }
1643
- function dispatch(name, data, o) {
1644
- switch (name) {
1645
- case "wf:token":
1646
- return o.onToken?.(data.text);
1647
- case "wf:tool_call":
1648
- return o.onToolCall?.(data);
1649
- case "wf:tool_result":
1650
- return o.onToolResult?.(data);
1651
- case "wf:tool_progress":
1652
- return o.onToolProgress?.(data);
1653
- case "wf:step":
1654
- return o.onStep?.(data);
1655
- case "wf:approval_request":
1656
- return o.onApproval?.(data);
1657
- case "wf:usage":
1658
- return o.onUsage?.(data);
1659
- case "wf:done":
1660
- return o.onDone?.(data);
1661
- case "wf:error":
1662
- return o.onError?.(data);
1663
- default:
1664
- return o.onEvent?.(name, data);
1665
- }
1666
- }
1667
- async function* parseWfEvents(stream) {
1668
- const reader = stream.getReader();
1669
- const decoder = new TextDecoder();
1670
- let buffer = "";
1671
- try {
1672
- while (true) {
1673
- const { done, value } = await reader.read();
1674
- if (done) break;
1675
- buffer += decoder.decode(value, { stream: true });
1676
- const blocks = buffer.split("\n\n");
1677
- buffer = blocks.pop() ?? "";
1678
- for (const block of blocks) {
1679
- let name = "message";
1680
- let data = "";
1681
- for (const line of block.split("\n")) {
1682
- if (line.startsWith("event: ")) name = line.slice(7);
1683
- else if (line.startsWith("data: ")) data += line.slice(6);
1684
- }
1685
- if (!data) continue;
1686
- try {
1687
- yield { name, data: JSON.parse(data) };
1688
- } catch {
1689
- }
1690
- }
1691
- }
1692
- } finally {
1693
- reader.releaseLock();
1694
- }
1695
- }
1696
- function pushEvent(events, e, record) {
1697
- if (!record) return;
1698
- if (events.length >= RECORD_LIMIT) events.shift();
1699
- events.push(e);
1700
- }
1701
- function httpErrorCode(status) {
1702
- if (status === 401 || status === 403) return "auth_failed";
1703
- if (status === 429) return "rate_limited";
1704
- if (status >= 500) return "provider_error";
1705
- return "invalid_request";
1706
- }
1707
- function randomId() {
1708
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1709
- return crypto.randomUUID();
1710
- }
1711
- return `trace-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1712
- }
1713
-
1714
1934
  // src/client/error-boundary.ts
1715
1935
  function ErrorBoundary(props, ctx) {
1716
1936
  let error = null;
@@ -1987,6 +2207,7 @@ export {
1987
2207
  jsxs,
1988
2208
  lockScroll,
1989
2209
  router,
2210
+ toChatMessages,
1990
2211
  trapFocus,
1991
2212
  unlockScroll,
1992
2213
  ws,
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * weifuwu/client 类型定义
3
3
  */
4
+ import type { UseChatHandle, UseChatOptions } from './use-chat.ts';
4
5
  /** 弹层位置跟踪配置 — 供 ctx.ui.usePopupPosition 使用 */
5
6
  export interface PopupPositionOptions {
6
7
  /** 锚定元素 getter(通常是 ref 保存的触发元素) */
@@ -33,6 +34,16 @@ export interface WfuiContext {
33
34
  dirty: (ids?: string[]) => void;
34
35
  /** 创建响应式状态容器:$.x = val 自动触发 dirty() */
35
36
  $: () => Record<string, any>;
37
+ /**
38
+ * AI 对话会话:$ 超集(会话语义 + 工具调用内嵌 + HITL 审批)
39
+ *
40
+ * ```tsx
41
+ * const $ = ctx.ui.useChat({ url: '/api/chat', approveUrl: '/api/approve' })
42
+ * // $.messages / $.input / $.streaming / $.error / $.usage / $.step
43
+ * // $.send() / $.stop() / $.retry() / $.clear() / $.approve(decision, note?)
44
+ * ```
45
+ */
46
+ useChat: (options: UseChatOptions) => UseChatHandle;
36
47
  /** 弹层位置跟踪:滚动/resize 时自动重算 fixed 坐标 */
37
48
  usePopupPosition: (options: PopupPositionOptions) => PopupPosition;
38
49
  /** 注册组件实例的自定义语义 ID,同名冲突抛错 */
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ctx.ui.useChat — AI 会话语义层(client)
3
+ *
4
+ * 在 aiStream(传输解码)之上加一层「一段对话」的语义:
5
+ * 消息累积、工具调用内嵌、HITL 审批、错误恢复、stop/retry/clear。
6
+ *
7
+ * - 协议透明:消费 wf: 事件(docs/ai-contract.md),页面不需要知道事件名
8
+ * - 状态即 $:useChat 返回组件同一个响应式 Proxy,赋值自动渲染
9
+ * - 就地累积:token 经 state.messages[idx] 代理就地 append(O(1)/token,
10
+ * 不重建数组;配合 key 稳定引用 → VDOM 只 patch 文本节点)
11
+ * - 工具内嵌:wf:tool_call/progress/result 按 toolCallId/id 聚合到
12
+ * 消息的 toolCalls[],ToolCallCard 直接消费(call/progress/result)
13
+ * - 审批:approval 挂消息,approve() POST approveUrl(协议 §4.5)
14
+ * - 生命周期:dispose() 中止流(组件 ref cleanup 调用;卸载防泄漏)
15
+ *
16
+ * 核心与框架无关:transport 注入(默认 aiStream),node 直接测。
17
+ */
18
+ import type { AiStreamCallbacks, AiStreamHandle } from './ai.ts';
19
+ import type { ChatMessage, WfApprovalDecision, WfApprovalRequest, WfError, WfStep, WfToolCall, WfToolProgress, WfToolResult, WfUsage } from '../ai/types.ts';
20
+ /** 工具调用显示条目:ToolCallCard 的三个 props 聚合 + 状态机 */
21
+ export interface UiToolCall {
22
+ call: WfToolCall;
23
+ progress?: WfToolProgress;
24
+ result?: WfToolResult;
25
+ status: 'running' | 'ok' | 'error';
26
+ }
27
+ /** 对话消息(显示模型):provider 形状 + UI 状态字段 */
28
+ export interface UiMessage {
29
+ id: string;
30
+ role: 'user' | 'assistant';
31
+ content: string;
32
+ status: 'streaming' | 'done' | 'error';
33
+ usage?: WfUsage;
34
+ toolCalls?: UiToolCall[];
35
+ approval?: WfApprovalRequest;
36
+ error?: WfError;
37
+ }
38
+ /** useChat 会话状态(挂在 $ 上,赋值自动渲染) */
39
+ export interface UseChatState {
40
+ messages: UiMessage[];
41
+ input: string;
42
+ streaming: boolean;
43
+ error: WfError | null;
44
+ usage: WfUsage | null;
45
+ /** 最近 wf:step(思考/工具指示),done/error 时清空 */
46
+ step: WfStep | null;
47
+ /** 页面自有状态与 chat 状态共处一个 $(与 ctx.ui.$() 一致) */
48
+ [key: string]: any;
49
+ }
50
+ /** useChat 操作(挂到 $ 上的方法;调用不触发渲染,内部 set 触发) */
51
+ export interface ChatApi {
52
+ /** 发送当前输入:追加 user + assistant 占位 → POST url */
53
+ send: () => void;
54
+ /** 中止当前流;空占位移除,有内容则标记 done */
55
+ stop: () => void;
56
+ /** 截断到最后一条 user 消息并重新生成其回复(错误恢复) */
57
+ retry: () => void;
58
+ /** 清空会话并中止 */
59
+ clear: () => void;
60
+ /** 响应 HITL 审批(协议 §4.5):清卡片 + POST approveUrl */
61
+ approve: (decision: WfApprovalDecision, note?: string) => Promise<void>;
62
+ /** 中止流并释放(组件卸载时调用) */
63
+ dispose: () => void;
64
+ /** 内部:订阅会话状态变更(AiChat 等共享 $ 的子组件用;返回退订)。
65
+ * 父组件 dirty 只驱动自身重渲染,共享 handle 的子组件需自行订阅。 */
66
+ __watch?: (cb: () => void) => () => void;
67
+ }
68
+ export type UseChatHandle = UseChatState & ChatApi;
69
+ export interface UseChatOptions {
70
+ /** POST 端点(返回 wf: SSE 流) */
71
+ url: string;
72
+ /** HITL 审批上行端点(协议 §4.5);缺省时 approve() 只清卡片不请求 */
73
+ approveUrl?: string;
74
+ /** 历史会话种子(app 数据注入;hook 不持有持久化) */
75
+ initialMessages?: UiMessage[];
76
+ /** 定制请求体(agent 模式携带 tools/mode 等);缺省 { messages } */
77
+ body?: (messages: UiMessage[]) => unknown;
78
+ headers?: Record<string, string>;
79
+ signal?: AbortSignal;
80
+ /** x:* 自定义事件透传(协议 §6) */
81
+ onEvent?: (name: string, data: unknown) => void;
82
+ }
83
+ /** 传输层(默认 aiStream):POST + SSE 解析 + abort */
84
+ export type ChatTransport = (url: string, body: unknown, callbacks: AiStreamCallbacks, opts?: {
85
+ signal?: AbortSignal;
86
+ headers?: Record<string, string>;
87
+ }) => AiStreamHandle;
88
+ /** UiMessage[] → provider ChatMessage[](剥离 UI 字段) */
89
+ export declare function toChatMessages(msgs: UiMessage[]): ChatMessage[];
90
+ export declare function createChatSession(state: UseChatState, transport: ChatTransport, options: UseChatOptions): ChatApi;
@@ -7,7 +7,7 @@
7
7
  * --jsxImportSource=weifuwu/client
8
8
  */
9
9
  import type { WfuiContext } from './types.ts';
10
- export type VNodeType = string | Component | AsyncComponent | typeof Fragment | typeof Portal;
10
+ export type VNodeType = string | Component<any, any> | AsyncComponent | typeof Fragment | typeof Portal;
11
11
  export interface VNode {
12
12
  type: VNodeType;
13
13
  props: Record<string, any>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * AiChat — 标准 AI 对话组件(useChat 的标准展示层)
3
+ *
4
+ * 组件库第一个领域复合组件:接收 ctx.ui.useChat() 返回的 handle,
5
+ * 渲染完整对话界面:消息气泡、工具调用卡(ToolCallCard)、HITL 审批卡
6
+ * (ApprovalCard)、思考/工具状态、usage、错误 + 重试、输入条 + 自动滚动。
7
+ *
8
+ * 分工:
9
+ * - useChat(hook)= 会话语义 + 协议(数据层)
10
+ * - AiChat(组件)= 标准界面(展示层,5 分钟出可交互对话页)
11
+ * - 需要完全自定义 UI 的应用直接用 useChat + 自有渲染(hook 保持灵活路径)
12
+ *
13
+ * 约定(手动优先):内部无 $,let + 事件;自动滚动经 ref + scroll 事件。
14
+ * 文案默认中文(与 ToolCallCard/ApprovalCard 一致),labels 可覆盖。
15
+ *
16
+ * ```tsx
17
+ * const $ = ctx.ui.useChat({ url: '/api/chat', approveUrl: '/api/approve' })
18
+ * return () => h(AiChat, { chat: $ })
19
+ * ```
20
+ */
21
+ import type { Component } from '../../client/vnode.ts';
22
+ import type { UseChatHandle, UiMessage } from '../../client/use-chat.ts';
23
+ import type { WfError, WfUsage } from '../../ai/types.ts';
24
+ export interface AiChatLabels {
25
+ send: string;
26
+ stop: string;
27
+ retry: string;
28
+ thinking: string;
29
+ runningTool: (name?: string) => string;
30
+ tokens: (u: WfUsage) => string;
31
+ error: (e: WfError) => string;
32
+ placeholder: string;
33
+ empty: string;
34
+ }
35
+ export interface AiChatProps {
36
+ /** ctx.ui.useChat() 返回的会话 handle(同一 $,状态变化自动重渲染) */
37
+ chat: UseChatHandle;
38
+ /** 消息列表最大高度(默认 '70vh') */
39
+ maxHeight?: string;
40
+ /** 界面文案覆盖 */
41
+ labels?: Partial<AiChatLabels>;
42
+ /** 自定义气泡渲染逃生舱(默认纯文本) */
43
+ renderMessage?: (msg: UiMessage) => any;
44
+ /** 工具参数渲染(透传 ToolCallCard) */
45
+ renderToolArgs?: (args: Record<string, unknown>) => any;
46
+ }
47
+ export declare const AiChat: Component<AiChatProps>;
@@ -98,3 +98,5 @@ export { ToolCallCard } from './ToolCallCard/ToolCallCard.ts';
98
98
  export type { ToolCallCardProps } from './ToolCallCard/ToolCallCard.ts';
99
99
  export { ApprovalCard } from './ApprovalCard/ApprovalCard.ts';
100
100
  export type { ApprovalCardProps, ApprovalStatus } from './ApprovalCard/ApprovalCard.ts';
101
+ export { AiChat } from './AiChat/AiChat.ts';
102
+ export type { AiChatProps, AiChatLabels } from './AiChat/AiChat.ts';
@@ -3205,8 +3205,130 @@ var ApprovalCard = (_init, ctx) => {
3205
3205
  ]);
3206
3206
  };
3207
3207
  };
3208
+
3209
+ // src/components/AiChat/AiChat.ts
3210
+ var defaultLabels = {
3211
+ send: "\u53D1\u9001",
3212
+ stop: "\u505C\u6B62",
3213
+ retry: "\u91CD\u8BD5",
3214
+ thinking: "\u{1F914} \u601D\u8003\u4E2D\u2026",
3215
+ runningTool: (name) => name ? `\u2699\uFE0F \u6267\u884C\u5DE5\u5177 ${name}` : "\u2699\uFE0F \u6267\u884C\u5DE5\u5177\u2026",
3216
+ tokens: (u) => `tokens: ${u.prompt_tokens}\u2192${u.completion_tokens}`,
3217
+ error: (e) => `${e.code}: ${e.message}`,
3218
+ placeholder: "\u8F93\u5165\u6D88\u606F\uFF0C\u56DE\u8F66\u53D1\u9001\u2026",
3219
+ empty: "\u8F93\u5165\u6D88\u606F\u5F00\u59CB\u5BF9\u8BDD\u3002"
3220
+ };
3221
+ var AiChat = (initProps, ctx) => {
3222
+ let listEl;
3223
+ let stickToBottom = true;
3224
+ const unwatch = initProps.chat.__watch?.(() => ctx.ui.dirty());
3225
+ const onScroll = () => {
3226
+ if (!listEl) return;
3227
+ stickToBottom = listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight < 48;
3228
+ };
3229
+ const scrollToBottom = () => {
3230
+ if (listEl) listEl.scrollTop = listEl.scrollHeight;
3231
+ };
3232
+ const listRef = (el) => {
3233
+ if (el && !listEl) {
3234
+ listEl = el;
3235
+ el.addEventListener("scroll", onScroll);
3236
+ queueMicrotask(scrollToBottom);
3237
+ } else if (!el && listEl) {
3238
+ listEl.removeEventListener("scroll", onScroll);
3239
+ listEl = void 0;
3240
+ unwatch?.();
3241
+ }
3242
+ };
3243
+ return (props) => {
3244
+ const { chat } = props;
3245
+ const labels = { ...defaultLabels, ...props.labels };
3246
+ if (stickToBottom) queueMicrotask(scrollToBottom);
3247
+ const msgs = chat.messages ?? [];
3248
+ const nodes = [];
3249
+ if (msgs.length === 0) {
3250
+ nodes.push(h("p", { class: "wf-aichat-empty" }, labels.empty));
3251
+ }
3252
+ for (const m of msgs) {
3253
+ nodes.push(renderMessage(m, props, labels));
3254
+ }
3255
+ if (chat.step) {
3256
+ nodes.push(
3257
+ h(
3258
+ "div",
3259
+ { class: "wf-aichat-status" },
3260
+ chat.step.type === "llm" ? labels.thinking : labels.runningTool(chat.step.name)
3261
+ )
3262
+ );
3263
+ }
3264
+ if (chat.usage) nodes.push(h("div", { class: "wf-aichat-usage" }, labels.tokens(chat.usage)));
3265
+ if (chat.error) nodes.push(h("div", { class: "wf-aichat-error" }, labels.error(chat.error)));
3266
+ return h("div", { class: "wf-aichat" }, [
3267
+ h("div", {
3268
+ class: "wf-aichat-list",
3269
+ style: { maxHeight: props.maxHeight ?? "70vh" },
3270
+ ref: listRef
3271
+ }, nodes),
3272
+ h("div", { class: "wf-aichat-inputbar" }, [
3273
+ h("input", {
3274
+ class: "wf-aichat-input",
3275
+ value: chat.input ?? "",
3276
+ placeholder: labels.placeholder,
3277
+ onInput: (e) => {
3278
+ chat.input = e.target.value;
3279
+ },
3280
+ onKeyDown: (e) => {
3281
+ if (e.key === "Enter") chat.send();
3282
+ }
3283
+ }),
3284
+ chat.streaming ? h("button", {
3285
+ class: "wf-btn wf-btn--primary wf-btn--sm",
3286
+ type: "button",
3287
+ onClick: () => chat.stop()
3288
+ }, labels.stop) : h("button", {
3289
+ class: "wf-btn wf-btn--primary wf-btn--sm",
3290
+ type: "button",
3291
+ onClick: () => chat.send()
3292
+ }, labels.send),
3293
+ !chat.streaming && chat.error ? h("button", {
3294
+ class: "wf-btn wf-btn--danger wf-btn--sm",
3295
+ type: "button",
3296
+ onClick: () => chat.retry()
3297
+ }, labels.retry) : null
3298
+ ])
3299
+ ]);
3300
+ };
3301
+ };
3302
+ function renderMessage(m, props, _labels) {
3303
+ const nodes = [];
3304
+ if (m.toolCalls?.length) {
3305
+ const cards = m.toolCalls.map((tc, i) => h(ToolCallCard, {
3306
+ key: `${m.id}-tool-${i}`,
3307
+ call: tc.call,
3308
+ progress: tc.progress,
3309
+ result: tc.result,
3310
+ renderArgs: props.renderToolArgs
3311
+ }));
3312
+ nodes.push(h("div", { class: "wf-aichat-tools" }, cards));
3313
+ }
3314
+ if (m.approval) {
3315
+ const card = h(ApprovalCard, {
3316
+ request: m.approval,
3317
+ onApprove: () => props.chat.approve("approved"),
3318
+ onReject: (note) => props.chat.approve("rejected", note ?? "\u7528\u6237\u62D2\u7EDD")
3319
+ });
3320
+ nodes.push(h("div", { class: "wf-aichat-approval" }, card));
3321
+ }
3322
+ nodes.push(h(
3323
+ "div",
3324
+ { class: `wf-aichat-bubble wf-aichat-bubble--${m.role}` },
3325
+ props.renderMessage ? props.renderMessage(m) : m.content || "\u2026"
3326
+ ));
3327
+ return h("div", { key: m.id, class: `wf-aichat-msg wf-aichat-msg--${m.role}` }, nodes);
3328
+ }
3208
3329
  export {
3209
3330
  Accordion,
3331
+ AiChat,
3210
3332
  Alert,
3211
3333
  ApprovalCard,
3212
3334
  Avatar,
@@ -4445,4 +4445,115 @@ code {
4445
4445
  color: var(--wf-color-text-secondary);
4446
4446
  }
4447
4447
 
4448
+ /* weifuwu/components — AiChat(标准 AI 对话界面) */
4449
+
4450
+ .wf-aichat {
4451
+ display: flex;
4452
+ flex-direction: column;
4453
+ gap: var(--wf-gap-sm, 8px);
4454
+ font-family: var(--wf-font-sans);
4455
+ font-size: var(--wf-font-size-sm, 14px);
4456
+ line-height: var(--wf-line-height);
4457
+ color: var(--wf-color-text);
4458
+ }
4459
+
4460
+ .wf-aichat-list {
4461
+ overflow-y: auto;
4462
+ display: flex;
4463
+ flex-direction: column;
4464
+ gap: var(--wf-gap-sm, 8px);
4465
+ padding: var(--wf-space-sm, 8px);
4466
+ border: 1px solid var(--wf-color-border);
4467
+ border-radius: var(--wf-radius);
4468
+ background: var(--wf-color-bg);
4469
+ }
4470
+
4471
+ .wf-aichat-empty {
4472
+ margin: var(--wf-space-lg, 16px) 0;
4473
+ text-align: center;
4474
+ color: var(--wf-color-text-secondary);
4475
+ font-size: var(--wf-font-size-sm, 14px);
4476
+ }
4477
+
4478
+ .wf-aichat-msg {
4479
+ display: flex;
4480
+ flex-direction: column;
4481
+ gap: var(--wf-gap-xs, 4px);
4482
+ }
4483
+
4484
+ .wf-aichat-tools,
4485
+ .wf-aichat-approval {
4486
+ max-width: 100%;
4487
+ }
4488
+
4489
+ .wf-aichat-bubble {
4490
+ display: inline-block;
4491
+ max-width: 85%;
4492
+ padding: var(--wf-space-sm, 8px) var(--wf-space-md, 12px);
4493
+ border-radius: var(--wf-radius);
4494
+ font-size: var(--wf-font-size-sm, 14px);
4495
+ white-space: pre-wrap;
4496
+ word-break: break-word;
4497
+ }
4498
+
4499
+ .wf-aichat-msg--user {
4500
+ align-items: flex-end;
4501
+ }
4502
+
4503
+ .wf-aichat-msg--assistant {
4504
+ align-items: flex-start;
4505
+ }
4506
+
4507
+ .wf-aichat-bubble--user {
4508
+ background: var(--wf-color-primary);
4509
+ color: #fff;
4510
+ }
4511
+
4512
+ .wf-aichat-bubble--assistant {
4513
+ background: var(--wf-color-bg-tertiary);
4514
+ color: var(--wf-color-text);
4515
+ }
4516
+
4517
+ .wf-aichat-status {
4518
+ font-size: var(--wf-font-size-xs, 12px);
4519
+ color: var(--wf-color-info);
4520
+ }
4521
+
4522
+ .wf-aichat-usage {
4523
+ font-size: var(--wf-font-size-xs, 12px);
4524
+ color: var(--wf-color-text-secondary);
4525
+ }
4526
+
4527
+ .wf-aichat-error {
4528
+ font-size: var(--wf-font-size-xs, 12px);
4529
+ color: var(--wf-color-error);
4530
+ background: var(--wf-color-error-bg);
4531
+ border-radius: var(--wf-radius);
4532
+ padding: var(--wf-space-sm, 8px);
4533
+ }
4534
+
4535
+ .wf-aichat-inputbar {
4536
+ display: flex;
4537
+ gap: var(--wf-gap-sm, 8px);
4538
+ align-items: center;
4539
+ }
4540
+
4541
+ .wf-aichat-input {
4542
+ flex: 1;
4543
+ min-width: 0;
4544
+ height: var(--wf-control-height, 36px);
4545
+ padding: var(--wf-control-pad-y, 6px) var(--wf-space-md, 12px);
4546
+ border: 1px solid var(--wf-color-border);
4547
+ border-radius: var(--wf-radius);
4548
+ font-family: var(--wf-font-sans);
4549
+ font-size: var(--wf-font-size-sm, 14px);
4550
+ color: var(--wf-color-text);
4551
+ background: var(--wf-color-bg);
4552
+ }
4553
+
4554
+ .wf-aichat-input:focus {
4555
+ outline: none;
4556
+ border-color: var(--wf-color-primary);
4557
+ }
4558
+
4448
4559
  }
package/dist/index.js CHANGED
@@ -1129,7 +1129,7 @@ function rateLimit(options) {
1129
1129
  if (opts.algorithm === "fixed") {
1130
1130
  const count2 = await redisPool.incr(PREFIX + key);
1131
1131
  if (count2 === 1) {
1132
- await redisPool.expire(PREFIX + key, Math.ceil(windowMs / 1e3));
1132
+ await redisPool.command("PEXPIRE", PREFIX + key, windowMs);
1133
1133
  }
1134
1134
  return {
1135
1135
  allowed: count2 <= max,
@@ -2401,6 +2401,7 @@ function queue(options) {
2401
2401
  const consumer = opts?.consumer ?? `${hostname()}-${process.pid}-${Math.random().toString(36).slice(2, 6)}`;
2402
2402
  const concurrency = opts?.concurrency ?? 1;
2403
2403
  const visibilityTimeout = opts?.visibilityTimeout ?? 3e4;
2404
+ const blockMs = opts?.blockMs ?? 1e3;
2404
2405
  const s = stream(name);
2405
2406
  const dead = deadStream(name);
2406
2407
  const delayed = `${prefix}${name}:delayed`;
@@ -2520,7 +2521,7 @@ function queue(options) {
2520
2521
  "COUNT",
2521
2522
  String(concurrency),
2522
2523
  "BLOCK",
2523
- "1000",
2524
+ String(blockMs),
2524
2525
  "STREAMS",
2525
2526
  s,
2526
2527
  ">"
@@ -2977,6 +2978,7 @@ var Portal = /* @__PURE__ */ Symbol("Portal");
2977
2978
  // src/client/reactive.ts
2978
2979
  function createReactiveState(dirty) {
2979
2980
  const proxyCache = /* @__PURE__ */ new WeakMap();
2981
+ const watchers = /* @__PURE__ */ new Set();
2980
2982
  const reactive = (target) => {
2981
2983
  if (target === null || typeof target !== "object") return target;
2982
2984
  if (proxyCache.has(target)) return proxyCache.get(target);
@@ -2986,6 +2988,7 @@ function createReactiveState(dirty) {
2986
2988
  if (old === value) return true;
2987
2989
  Reflect.set(target2, key, value);
2988
2990
  dirty();
2991
+ for (const w of watchers) w();
2989
2992
  return true;
2990
2993
  },
2991
2994
  get(target2, key) {
@@ -2997,6 +3000,7 @@ function createReactiveState(dirty) {
2997
3000
  if (Reflect.has(target2, key)) {
2998
3001
  Reflect.deleteProperty(target2, key);
2999
3002
  dirty();
3003
+ for (const w of watchers) w();
3000
3004
  }
3001
3005
  return true;
3002
3006
  }
@@ -3004,7 +3008,18 @@ function createReactiveState(dirty) {
3004
3008
  proxyCache.set(target, proxy);
3005
3009
  return proxy;
3006
3010
  };
3007
- return reactive({});
3011
+ const root = reactive({});
3012
+ Object.defineProperty(root, "__watch", {
3013
+ value: (cb) => {
3014
+ watchers.add(cb);
3015
+ return () => {
3016
+ watchers.delete(cb);
3017
+ };
3018
+ },
3019
+ writable: false,
3020
+ enumerable: false
3021
+ });
3022
+ return root;
3008
3023
  }
3009
3024
 
3010
3025
  // src/ui/ssr.ts
@@ -3060,7 +3075,28 @@ function createSsrContext(serverCtx, dataStore) {
3060
3075
  useBreakpoint: () => {
3061
3076
  },
3062
3077
  usePopupPosition: () => ({ top: 0, left: 0, refresh: () => {
3063
- } })
3078
+ } }),
3079
+ // SSR 确定性空态:会话不启动(无事件/无网络),仅保证挂载不崩
3080
+ useChat: () => ({
3081
+ messages: [],
3082
+ input: "",
3083
+ streaming: false,
3084
+ error: null,
3085
+ usage: null,
3086
+ step: null,
3087
+ send: () => {
3088
+ },
3089
+ stop: () => {
3090
+ },
3091
+ retry: () => {
3092
+ },
3093
+ clear: () => {
3094
+ },
3095
+ approve: async () => {
3096
+ },
3097
+ dispose: () => {
3098
+ }
3099
+ })
3064
3100
  };
3065
3101
  const ctx = Object.create(serverCtx ?? {});
3066
3102
  ctx.ui = ui2;
@@ -57,6 +57,10 @@ export interface WorkerOptions {
57
57
  visibilityTimeout?: number;
58
58
  /** 消费组 consumer 名(多实例自动 hostname-pid,可覆盖) */
59
59
  consumer?: string;
60
+ /** XREADGROUP 阻塞等待时长(ms)。默认 1000。
61
+ * 越小失败重投延迟越低(重投间隔 = max(visibilityTimeout, blockMs)),
62
+ * 测试用短值提速;生产保持默认。 */
63
+ blockMs?: number;
60
64
  }
61
65
  export interface QueueWorker {
62
66
  /** 启动消费循环(阻塞直到 stop) */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.59.0",
4
+ "version": "0.60.0",
5
5
  "description": "AI SaaS framework — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {