diff --git a/.changeset/long-ads-enjoy.md b/.changeset/long-ads-enjoy.md new file mode 100644 index 00000000..9a196a73 --- /dev/null +++ b/.changeset/long-ads-enjoy.md @@ -0,0 +1,7 @@ +--- +"@varo-ui/cli": minor +"@varo-ui/h5": patch +"@varo-ui/weapp": patch +--- + +Add a five-stage RAG pipeline with linked citation motion, cancellation and reduced-motion support; synchronize public H5/Weapp stylesheets with the Registry motion and source-color tokens, and keep native workspace close controls aligned to the trailing edge. diff --git a/apps/docs/ai/index.md b/apps/docs/ai/index.md index b96b937e..8b96fa04 100644 --- a/apps/docs/ai/index.md +++ b/apps/docs/ai/index.md @@ -64,18 +64,19 @@ pnpm dlx @varo-ui/cli add --target weapp blocks/agent-workspace ## 上下文、引用与产物 -| 组件 | 用途 | -| ------------------------------------------ | -------------------------- | -| [AgentArtifact](./artifact) | 代码、文档、文件和图片产物 | -| [AgentAttachmentList](./attachments) | 附件预览与移除 | -| [AgentSourceList](./sources) | 来源列表 | -| [AgentCitations](./citations) | 可折叠引用集合 | -| `AgentRetrievalProgress` | 可见检索队列与失败重试 | -| `AgentSourceReceipt` | 回答完成后的来源读取回执 | -| [AgentContextCard](./context-card) | 检索知识块 | -| [AgentCodeBlock](./code-block) | 独立流式代码块 | -| [AgentFileDiff](./file-diff) | 文件级差异 | -| [AgentImageGeneration](./image-generation) | 图片生成生命周期 | +| 组件 | 用途 | +| ------------------------------------------ | ------------------------------------ | +| [AgentArtifact](./artifact) | 代码、文档、文件和图片产物 | +| [AgentAttachmentList](./attachments) | 附件预览与移除 | +| [AgentSourceList](./sources) | 来源列表 | +| [AgentCitations](./citations) | 可折叠引用集合 | +| `AgentRetrievalProgress` | 可见检索队列与失败重试 | +| `AgentRagPipeline` | 五阶段 RAG、上下文组装与流式引用联动 | +| `AgentSourceReceipt` | 回答完成后的来源读取回执 | +| [AgentContextCard](./context-card) | 检索知识块 | +| [AgentCodeBlock](./code-block) | 独立流式代码块 | +| [AgentFileDiff](./file-diff) | 文件级差异 | +| [AgentImageGeneration](./image-generation) | 图片生成生命周期 | ## 结构化数据与工作区 @@ -97,6 +98,7 @@ pnpm dlx @varo-ui/cli add --target weapp blocks/agent-workspace | ------------------------ | ------------------------------------- | ---------------------------------------------------- | | `AgentComposerScope` | `sources`、`usagePercent`、`disabled` | `toggle(source, enabled)`、`connect(source)` | | `AgentRetrievalProgress` | `items` | `retry(item)` | +| `AgentRagPipeline` | `query`、`steps`、`sources`、`answer` | `run`、`cancel`、`selectSource(source)` | | `AgentSourceReceipt` | `items`、`summary` | `open(item)`、`connect(item)` | | `AgentTaskRunner` | `tasks`、`busy` | `retry(task)`、`approve(task)`、`cancel` | | `AgentThreadVersions` | `versions`、`activeId` | `select(version)`、`branch(version)`、`pin(version)` | @@ -104,6 +106,21 @@ pnpm dlx @varo-ui/cli add --target weapp blocks/agent-workspace `createAgentThreadController()` 在 `@varo-ui/ai` 中拥有不可变、无环的版本图;组件只渲染 snapshot 并转发决策。`AgentWorkspace` 使用 `toggleSource`、`retryRetrieval`、`retryTask`、`selectVersion` 等 qualified events,避免不同 surface 的 `retry` / `connect` 冲突。 +### RAG 流程与动效 + +`AgentRagPipeline` 随 `components/agent-ui` 安装。它只投影受控快照,不执行检索、模型请求或来源授权。业务层按实际进度替换输入: + +- `steps`:固定 `query`、`embed`、`retrieve`、`assemble`、`generate` 五个阶段;状态为 `waiting`、`running`、`completed` 或 `failed`。可提供 `detail` 与 `durationMs`。仍有阶段运行时,整体保持运行状态并允许停止;没有运行阶段后再呈现失败结果。 +- `sources`:稳定 `id` 与 `title`,可附带 `excerpt`、0–1 的 `score`、上下文 `tokens` 和 `blue/violet/rose` 色调。仅当所有来源都有有限正数 `tokens` 时按权重分配片段,否则等分;片段保留可读的最小宽度。 +- `answer`:带稳定 `id` 的文本片段 `{ type: 'text', text }` 或引用 `{ type: 'citation', sourceId }`。引用通过来源 ID 关联,不依赖数组位置。删除或更换引用映射会清理已失效的来源反馈,不重播未变化的引用。 +- `title`、`elapsedMs`、`className` 控制标题、总耗时与容器;`reducedMotion` 关闭非必要动效。 + +新引用出现时,对应来源与上下文片段短暂高亮;H5 的悬停、键盘聚焦及点击均能联动来源,小程序使用点击选择。来源缺失时,H5 显示禁用引用按钮,小程序显示不可交互的 `[?]` 文本,并通过可访问标签说明来源不可用;正文仍可阅读。停止、重播与卸载会清理演示定时器;重播沿用当前查询,组件的业务状态不等待动画结束。 + +H5 自动遵循 `prefers-reduced-motion`,小程序同时提供 `reducedMotion` 属性与媒体查询样式。动效使用主题中的 `--varo-agent-motion-*` 变量以及 transform/opacity,不引入动画运行时依赖。 + +`AgentWorkspace` 的 `execution` 插槽可放入此组件,替换默认的对话、检索、任务与回执区域;来源授权、版本与输入区仍由 Workspace 组合。Weapp 的 `weapp-vite` 配置应设置 `weapp.vue.template.scopedSlotsRequireProps: true`,让没有插槽参数的 `#execution` 保持普通命名插槽,避免 7.0.4 的泛型插槽自动 `setData.pick` 遗漏父级状态。两端 playground 提供可取消、重播的演示数据流程。 + ## 完整 Block - [AgentChat](./agent-chat):组合消息历史、流式回答、推理、工具、审批和输入区。 diff --git a/apps/docs/en/ai/index.md b/apps/docs/en/ai/index.md index 77ed5931..601af693 100644 --- a/apps/docs/en/ai/index.md +++ b/apps/docs/en/ai/index.md @@ -64,18 +64,19 @@ pnpm dlx @varo-ui/cli add --target weapp blocks/agent-workspace ## Context, Citations, and Artifacts -| Component | Responsibility | -| ------------------------------------------ | --------------------------------------------- | -| [AgentArtifact](./artifact) | Code, document, file, and image artifacts | -| [AgentAttachmentList](./attachments) | Attachment preview and removal | -| [AgentSourceList](./sources) | Source list | -| [AgentCitations](./citations) | Collapsible citations | -| `AgentRetrievalProgress` | Visible retrieval queue and failed-item retry | -| `AgentSourceReceipt` | Post-answer source read receipt | -| [AgentContextCard](./context-card) | Retrieved knowledge chunks | -| [AgentCodeBlock](./code-block) | Standalone streaming code block | -| [AgentFileDiff](./file-diff) | File-level diff | -| [AgentImageGeneration](./image-generation) | Image generation lifecycle | +| Component | Responsibility | +| ------------------------------------------ | ---------------------------------------------------------------- | +| [AgentArtifact](./artifact) | Code, document, file, and image artifacts | +| [AgentAttachmentList](./attachments) | Attachment preview and removal | +| [AgentSourceList](./sources) | Source list | +| [AgentCitations](./citations) | Collapsible citations | +| `AgentRetrievalProgress` | Visible retrieval queue and failed-item retry | +| `AgentRagPipeline` | Five-stage RAG, context assembly, and linked streaming citations | +| `AgentSourceReceipt` | Post-answer source read receipt | +| [AgentContextCard](./context-card) | Retrieved knowledge chunks | +| [AgentCodeBlock](./code-block) | Standalone streaming code block | +| [AgentFileDiff](./file-diff) | File-level diff | +| [AgentImageGeneration](./image-generation) | Image generation lifecycle | ## Structured Data and Workspace @@ -97,6 +98,7 @@ pnpm dlx @varo-ui/cli add --target weapp blocks/agent-workspace | ------------------------ | ------------------------------------- | ---------------------------------------------------- | | `AgentComposerScope` | `sources`, `usagePercent`, `disabled` | `toggle(source, enabled)`, `connect(source)` | | `AgentRetrievalProgress` | `items` | `retry(item)` | +| `AgentRagPipeline` | `query`, `steps`, `sources`, `answer` | `run`, `cancel`, `selectSource(source)` | | `AgentSourceReceipt` | `items`, `summary` | `open(item)`, `connect(item)` | | `AgentTaskRunner` | `tasks`, `busy` | `retry(task)`, `approve(task)`, `cancel` | | `AgentThreadVersions` | `versions`, `activeId` | `select(version)`, `branch(version)`, `pin(version)` | @@ -104,6 +106,21 @@ pnpm dlx @varo-ui/cli add --target weapp blocks/agent-workspace `createAgentThreadController()` in `@varo-ui/ai` owns the immutable acyclic version graph; components only render its snapshot and forward decisions. `AgentWorkspace` exposes qualified events such as `toggleSource`, `retryRetrieval`, `retryTask`, and `selectVersion` so unrelated `retry` and `connect` surfaces never collide. +### RAG Pipeline and Motion + +`AgentRagPipeline` is installed with `components/agent-ui`. It projects controlled snapshots; it does not perform retrieval, model requests, or source authorization. Replace its inputs as real work progresses: + +- `steps`: the fixed `query`, `embed`, `retrieve`, `assemble`, and `generate` stages, each with `waiting`, `running`, `completed`, or `failed` status. Optional `detail` and `durationMs` describe progress. While any stage is running, the overall flow remains active and cancellable; failure takes precedence once no stage remains active. +- `sources`: stable `id` and `title`, with optional `excerpt`, a 0–1 `score`, context `tokens`, and a `blue/violet/rose` tone. Segments use token weights only when every source has a finite positive token count; otherwise they share space equally and retain a readable minimum width. +- `answer`: text parts `{ type: 'text', text }` or citations `{ type: 'citation', sourceId }`, each carrying a stable `id`. Citations resolve by source ID, not array position. Removing or remapping citations clears obsolete source feedback without replaying unchanged citations. +- `title`, `elapsedMs`, and `className` control the heading, total elapsed time, and container; `reducedMotion` disables nonessential motion. + +A newly arriving citation briefly highlights its source and context segment. H5 links pointer hover, keyboard focus, and selection; Weapp uses tap selection. Missing sources produce a disabled citation button on H5 and noninteractive `[?]` text on Weapp, with an accessible label describing the unavailable source; readable answer text is preserved. Cancellation, replay, and unmount clean up demo timers. Replay retains the current query, and business state never waits for an animation to finish. + +H5 follows `prefers-reduced-motion` automatically. Weapp provides the `reducedMotion` prop and media-query styles. Motion uses theme `--varo-agent-motion-*` variables with transform/opacity and adds no animation runtime dependency. + +Place the component in the `AgentWorkspace` `execution` slot to replace its default conversation, retrieval, task, and receipt region while retaining source permissions, thread versions, and input. In Weapp, set `weapp.vue.template.scopedSlotsRequireProps: true` in the `weapp-vite` configuration so a `#execution` slot without scope parameters remains an ordinary named slot. This avoids version 7.0.4's generic-slot automatic `setData.pick` omitting parent state. Both playgrounds provide a cancellable, replayable demo-data flow. + ## Complete Block - [AgentChat](./agent-chat): composes history, streaming responses, reasoning, tools, approval, and prompt input. diff --git a/apps/playground-h5/src/agent-rag.test.ts b/apps/playground-h5/src/agent-rag.test.ts new file mode 100644 index 00000000..69975fb7 --- /dev/null +++ b/apps/playground-h5/src/agent-rag.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import type { AgentRagAnswerPart, AgentRagSource } from './components/agent-ui/rag-pipeline' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import AgentRagPipeline from './components/agent-ui/AgentRagPipeline.vue' +import { useRagDemo } from './features/useRagDemo' + +enableAutoUnmount(afterEach) +afterEach(() => vi.useRealTimers()) + +const source: AgentRagSource = { id: 'source-a', title: 'Source A', tone: 'blue' } +const answer: AgentRagAnswerPart[] = [ + { id: 'text-a', type: 'text', text: 'An answer with evidence ' }, + { id: 'citation-a', type: 'citation', sourceId: source.id }, +] + +describe('RAG source references', () => { + it('keeps the answer readable while source metadata disappears and returns', async () => { + const wrapper = mount(AgentRagPipeline, { props: { sources: [source], answer } }) + await wrapper.setProps({ sources: [] }) + expect(wrapper.text()).toContain('An answer with evidence') + expect(wrapper.get('[data-rag-citation="source-a"]').attributes('disabled')).toBeDefined() + + await wrapper.setProps({ sources: [source] }) + const citation = wrapper.get('[data-rag-citation="source-a"]') + expect(citation.attributes('disabled')).toBeUndefined() + await citation.trigger('focus') + expect(wrapper.get('[data-rag-source="source-a"]').classes()).toContain('is-highlighted') + }) + + it('does not replay citation pings for text-only updates', async () => { + vi.useFakeTimers() + const wrapper = mount(AgentRagPipeline, { props: { sources: [source], answer: [] } }) + await wrapper.setProps({ answer }) + expect(wrapper.get('[data-rag-source="source-a"]').classes()).toContain('is-pinging') + await vi.runOnlyPendingTimersAsync() + expect(wrapper.get('[data-rag-source="source-a"]').classes()).not.toContain('is-pinging') + + await wrapper.setProps({ answer: [...answer, { id: 'text-b', type: 'text', text: 'and more text.' }] }) + expect(wrapper.get('[data-rag-source="source-a"]').classes()).not.toContain('is-pinging') + }) + + it('follows the latest pointer or keyboard source interaction', async () => { + const secondSource: AgentRagSource = { id: 'source-b', title: 'Source B', tone: 'violet' } + const wrapper = mount(AgentRagPipeline, { + props: { + sources: [source, secondSource], + answer: [...answer, { id: 'citation-b', type: 'citation', sourceId: secondSource.id }], + }, + }) + const first = wrapper.get('[data-rag-source="source-a"]') + const second = wrapper.get('[data-rag-source="source-b"]') + const citation = wrapper.get('[data-rag-citation="source-b"]') + await first.trigger('focus') + await citation.trigger('mouseenter') + expect(second.classes()).toContain('is-highlighted') + expect(first.classes()).not.toContain('is-highlighted') + + await first.trigger('focus') + expect(first.classes()).toContain('is-highlighted') + await citation.trigger('mousemove') + expect(second.classes()).toContain('is-highlighted') + await first.trigger('click') + expect(first.classes()).toContain('is-highlighted') + expect(second.classes()).not.toContain('is-highlighted') + }) + + it('preserves a submitted query across cancellation and completed replays', async () => { + vi.useFakeTimers() + let demo!: ReturnType + mount(defineComponent({ + setup() { + demo = useRagDemo(() => [{ id: 'support', label: 'Support', enabled: true, status: 'available' }], 'en') + return () => null + }, + })) + const query = 'Explain recovery after a payment failure' + const firstRun = demo.run(query) + await vi.advanceTimersByTimeAsync(400) + demo.cancel() + await firstRun + + const replay = demo.run() + expect(demo.snapshot.value.query).toBe(query) + await vi.runAllTimersAsync() + await replay + + const completedReplay = demo.run() + expect(demo.snapshot.value.query).toBe(query) + await vi.runAllTimersAsync() + await completedReplay + expect(demo.snapshot.value.steps.every(step => step.status === 'completed')).toBe(true) + }) + + it('removes only feedback whose source loses its last citation', async () => { + vi.useFakeTimers() + const second: AgentRagSource = { id: 'source-b', title: 'Source B', tone: 'violet' } + const secondCitation: AgentRagAnswerPart = { id: 'citation-b', type: 'citation', sourceId: second.id } + const wrapper = mount(AgentRagPipeline, { props: { sources: [source, second], answer: [] } }) + await wrapper.setProps({ answer: [...answer, secondCitation] }) + await wrapper.setProps({ answer: [secondCitation] }) + expect(wrapper.get('[data-rag-source="source-a"]').classes()).not.toContain('is-pinging') + expect(wrapper.get('[data-rag-source="source-b"]').classes()).toContain('is-pinging') + }) + + it('reconciles a citation whose source mapping changes', async () => { + vi.useFakeTimers() + const second: AgentRagSource = { id: 'source-b', title: 'Source B', tone: 'violet' } + const wrapper = mount(AgentRagPipeline, { props: { sources: [source, second], answer: [] } }) + await wrapper.setProps({ answer }) + await wrapper.setProps({ answer: [{ id: 'citation-a', type: 'citation', sourceId: second.id }] }) + expect(wrapper.get('[data-rag-source="source-a"]').classes()).not.toContain('is-pinging') + expect(wrapper.get('[data-rag-source="source-b"]').classes()).toContain('is-pinging') + }) +}) diff --git a/apps/playground-h5/src/components/agent-ui/AgentRagPipeline.vue b/apps/playground-h5/src/components/agent-ui/AgentRagPipeline.vue new file mode 100644 index 00000000..6bd149f3 --- /dev/null +++ b/apps/playground-h5/src/components/agent-ui/AgentRagPipeline.vue @@ -0,0 +1,1078 @@ + + + + + diff --git a/apps/playground-h5/src/components/agent-ui/agent-ui.css b/apps/playground-h5/src/components/agent-ui/agent-ui.css index 79f5cf5f..f3958133 100644 --- a/apps/playground-h5/src/components/agent-ui/agent-ui.css +++ b/apps/playground-h5/src/components/agent-ui/agent-ui.css @@ -1,22 +1,44 @@ @keyframes varo-agent-pulse { - 0%, 100% { opacity: .28; transform: scale(.82); } - 50% { opacity: 1; transform: scale(1); } + 0%, + 100% { + opacity: 0.35; + } + + 50% { + opacity: 1; + } } @keyframes varo-agent-cursor { - 0%, 48% { opacity: 1; } - 49%, 100% { opacity: 0; } + 0%, + 48% { + opacity: 1; + } + + 49%, + 100% { + opacity: 0; + } +} + +.agent-ui__loading i { + animation: varo-agent-pulse 1s ease-in-out infinite; +} + +.agent-ui__pulse { + animation: varo-agent-pulse 1s ease-in-out infinite; +} + +.agent-ui__cursor { + animation: varo-agent-cursor 0.72s steps(1) infinite; } -.agent-ui__loading i { animation: varo-agent-pulse 1s ease-in-out infinite; } -.agent-ui__pulse { animation: varo-agent-pulse 1s ease-in-out infinite; } -.agent-ui__cursor { animation: varo-agent-cursor .72s steps(1) infinite; } .agent-thinking { - --agent-thinking-surface: var(--varo-surface, #fff); - --agent-thinking-surface-strong: var(--varo-surface-strong, #f8fafc); - --agent-thinking-border: var(--varo-border, #dbe3ea); - --agent-thinking-text: var(--varo-foreground, #172033); - --agent-thinking-muted: var(--varo-muted, #667085); + --agent-thinking-surface: var(--varo-agent-surface, #fff); + --agent-thinking-surface-strong: var(--varo-agent-surface-strong, #f8fafc); + --agent-thinking-border: var(--varo-agent-border, #dbe3ea); + --agent-thinking-text: var(--varo-agent-foreground, #172033); + --agent-thinking-muted: var(--varo-agent-muted, #667085); overflow: hidden; color: var(--agent-thinking-text); @@ -46,16 +68,41 @@ place-items: center; width: 34px; height: 34px; - color: #0f766e; - background: linear-gradient(145deg, #f0fdfa, #e6fffa); - border: 1px solid #b9eee5; + color: var(--varo-agent-primary, #0f766e); + background: var(--varo-agent-primary-soft, #f0fdfa); + border: 1px solid color-mix(in srgb, var(--varo-agent-primary, #0f766e) 38%, var(--varo-agent-border, #dbe3ea)); border-radius: 11px; - box-shadow: 0 4px 12px rgb(15 118 110 / 10%); + box-shadow: 0 4px 12px color-mix(in srgb, var(--varo-agent-primary, #0f766e) 10%, transparent); +} + +.agent-thinking__icon svg { + display: block; + width: 19px; + height: 19px; +} + +.agent-thinking__copy { + display: grid; + flex: 1; + gap: 2px; + min-width: 0; +} + +.agent-thinking__title { + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + font-weight: 760; + line-height: 18px; + color: var(--agent-thinking-text); + white-space: nowrap; +} + +.agent-thinking__summary { + font-size: 11px; + line-height: 16px; + color: var(--agent-thinking-muted); } -.agent-thinking__icon svg { display: block; width: 19px; height: 19px; } -.agent-thinking__copy { display: grid; flex: 1; gap: 2px; min-width: 0; } -.agent-thinking__title { overflow: hidden; text-overflow: ellipsis; font-size: 13px; font-weight: 760; line-height: 18px; color: var(--agent-thinking-text); white-space: nowrap; } -.agent-thinking__summary { font-size: 11px; line-height: 16px; color: var(--agent-thinking-muted); } .agent-thinking__chevron { display: block; @@ -65,7 +112,10 @@ color: var(--agent-thinking-muted); transition: transform 200ms ease; } -.agent-thinking[data-open='true'] .agent-thinking__chevron { transform: rotate(180deg); } + +.agent-thinking[data-open='true'] .agent-thinking__chevron { + transform: rotate(180deg); +} .agent-thinking__body { display: grid; @@ -94,25 +144,268 @@ border: 2px solid var(--agent-thinking-surface-strong); border-radius: 999px; } -.agent-thinking__step[data-status='completed'] .agent-thinking__dot { background: #16a34a; } -.agent-thinking__step[data-status='running'] .agent-thinking__dot { background: #0f766e; } -.agent-thinking__step[data-status='failed'] .agent-thinking__dot { background: #dc2626; } -.agent-thinking__step-copy { display: grid; flex: 1; gap: 3px; min-width: 0; } -.agent-thinking__step-header { display: flex; gap: 10px; align-items: center; justify-content: space-between; } -.agent-thinking__step-header strong { font-size: 12px; font-weight: 680; line-height: 17px; color: var(--agent-thinking-text); } -.agent-thinking__step-header small, .agent-thinking__detail { font-size: 11px; line-height: 16px; color: var(--agent-thinking-muted); } + +.agent-thinking__step[data-status='completed'] .agent-thinking__dot { + background: var(--varo-agent-success, #16a34a); +} + +.agent-thinking__step[data-status='running'] .agent-thinking__dot { + background: var(--varo-agent-primary, #0f766e); +} + +.agent-thinking__step[data-status='failed'] .agent-thinking__dot { + background: var(--varo-agent-danger, #dc2626); +} + +.agent-thinking__step-copy { + display: grid; + flex: 1; + gap: 3px; + min-width: 0; +} + +.agent-thinking__step-header { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; +} + +.agent-thinking__step-header strong { + font-size: 12px; + font-weight: 680; + line-height: 17px; + color: var(--agent-thinking-text); +} + +.agent-thinking__step-header small, +.agent-thinking__detail { + font-size: 11px; + line-height: 16px; + color: var(--agent-thinking-muted); +} + +@media (max-width: 640px) { + .agent-thinking .agent-thinking__trigger { + min-height: 60px; + padding-inline: 12px; + } + + .agent-thinking__body { + padding-right: 12px; + padding-left: 27px; + } +} + +.agent-approval { + display: grid; + gap: 14px; + padding: 16px; + color: var(--varo-agent-foreground, #172033); + background: var(--varo-agent-surface, #fff); + border: 1px solid var(--varo-agent-border, #dbe3ea); + border-radius: 14px; + box-shadow: var(--varo-ui-shadow-sm, 0 1px 2px rgb(15 23 42 / 6%)); +} + +.agent-approval__header { + display: flex; + gap: 11px; + align-items: flex-start; +} + +.agent-approval__icon { + display: grid; + flex: none; + place-items: center; + width: 32px; + height: 32px; + font-weight: 850; + color: var(--varo-ui-warning-dark, #b45309); + background: var(--varo-agent-warning-soft, #fffbeb); + border-radius: 9px; +} + +.agent-approval__icon svg { + width: 20px; + height: 20px; +} + +.agent-approval__heading, +.agent-approval__choice-copy { + display: grid; + min-width: 0; +} + +.agent-approval__heading { + gap: 2px; +} + +.agent-approval__eyebrow { + font-size: 0.68rem; + font-weight: 800; + color: var(--varo-agent-warning, #d97706); + letter-spacing: 0.08em; +} + +.agent-approval__title { + font-size: 0.94rem; + line-height: 1.4; + color: var(--varo-agent-foreground, #172033); +} + +.agent-approval__description { + font-size: 0.76rem; + line-height: 1.55; + color: var(--varo-agent-text, #475569); +} + +.agent-approval__choices { + display: grid; + gap: 8px; +} + +.agent-approval__choice { + display: flex; + gap: 10px; + align-items: center; + min-height: 52px; + padding: 8px 11px; + color: var(--varo-agent-foreground, #172033); + cursor: pointer; + background: var(--varo-agent-fill, #f1f5f9); + border: 1px solid var(--varo-agent-border, #dbe3ea); + border-radius: 10px; + transition: + background 140ms ease, + border-color 140ms ease; +} + +.agent-approval__choice:hover { + border-color: var(--varo-agent-border-strong, #cbd5e1); +} + +.agent-approval__choice[data-selected='true'] { + background: var(--varo-agent-primary-soft, #f0fdfa); + border-color: var(--varo-agent-primary, #0f766e); +} + +.agent-approval__choice[data-disabled='true'] { + cursor: not-allowed; + opacity: 0.5; +} + +.agent-approval__radio { + flex: none; + width: 18px; + height: 18px; + margin: 0; + appearance: none; + cursor: inherit; + background: var(--varo-agent-surface, #fff); + border: 1px solid var(--varo-agent-border-strong, #cbd5e1); + border-radius: 999px; +} + +.agent-approval__radio:checked { + background: var(--varo-agent-primary, #0f766e); + box-shadow: inset 0 0 0 4px var(--varo-agent-surface, #fff); +} + +.agent-approval__choice-copy { + gap: 2px; +} + +.agent-approval__choice-copy strong { + font-size: 0.78rem; + color: var(--varo-agent-foreground, #172033); +} + +.agent-approval__choice-copy small { + font-size: 0.7rem; + color: var(--varo-agent-muted, #667085); +} + +.agent-approval__footer { + display: flex; + gap: 8px; + justify-content: flex-end; + padding-top: 2px; +} + +.agent-approval__reject, +.agent-approval__approve { + min-height: 36px; + padding: 0 13px; + font: inherit; + font-size: 0.76rem; + font-weight: 700; + cursor: pointer; + border-radius: 8px; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease; +} + +.agent-approval__reject { + color: var(--varo-agent-text, #475569); + background: var(--varo-agent-surface, #fff); + border: 1px solid var(--varo-agent-border, #dbe3ea); +} + +.agent-approval__reject:hover { + background: var(--varo-agent-fill, #f1f5f9); + border-color: var(--varo-agent-border-strong, #cbd5e1); +} + +.agent-approval__approve { + color: var(--varo-agent-primary-foreground, #fff); + background: var(--varo-agent-primary, #0f766e); + border: 0; +} + +.agent-approval__approve:hover:not(:disabled) { + background: var(--varo-ui-primary-hover, #38cd80); +} + +.agent-approval__approve:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.agent-approval :is(button, input):focus-visible { + outline: 2px solid var(--varo-agent-primary, #0f766e); + outline-offset: 2px; +} + +.agent-composer__shell { + transition: + border-color 160ms ease, + box-shadow 160ms ease; +} + +.agent-composer__shell:focus-within { + border-color: var(--varo-agent-primary, #0f766e); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--varo-agent-primary, #0f766e) 14%, transparent); +} @media (max-width: 640px) { - .agent-thinking .agent-thinking__trigger { min-height: 60px; padding-inline: 12px; } - .agent-thinking__body { padding-right: 12px; padding-left: 27px; } + .agent-approval { + padding: 14px; + } } @media (prefers-reduced-motion: reduce) { - .agent-thinking__chevron { transition: none; } + .agent-thinking__chevron { + transition: none; + } } @media (prefers-reduced-motion: reduce) { .agent-ui__loading i, .agent-ui__pulse, - .agent-ui__cursor { animation: none; } + .agent-ui__cursor { + animation: none; + } } diff --git a/apps/playground-h5/src/components/agent-ui/index.ts b/apps/playground-h5/src/components/agent-ui/index.ts index 5030c6e5..59e6df17 100644 --- a/apps/playground-h5/src/components/agent-ui/index.ts +++ b/apps/playground-h5/src/components/agent-ui/index.ts @@ -3,10 +3,9 @@ import type { AgentPartStatus, AgentStreamSnapshot, AgentStreamStatus, - AgentToolPart, AgentThreadVersion, + AgentToolPart, } from '@varo-ui/ai' -import { useBodyScrollLock } from '@varo-ui/headless' import type { PropType, ShallowRef } from 'vue' import type { ClassValue } from '../../lib/cn' import type { @@ -15,6 +14,7 @@ import type { AgentSourceReceiptItem, AgentWorkspacePlacement, } from './advanced-types' +import { useBodyScrollLock } from '@varo-ui/headless' import { computed, defineComponent, @@ -92,14 +92,28 @@ export interface AgentAttachmentItem { } const baseButton = 'inline-flex min-h-10 items-center justify-center rounded-xl border px-3 text-xs font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-45' -const primaryButton = `${baseButton} border-teal-700 bg-teal-700 text-white hover:bg-teal-800` -const quietButton = `${baseButton} border-slate-200 bg-white text-slate-700 hover:bg-slate-50` +const primaryButton = `${baseButton} border-[var(--varo-agent-primary)] bg-[var(--varo-agent-primary)] text-[var(--varo-agent-primary-foreground)] hover:opacity-90` +const quietButton = `${baseButton} border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] text-[var(--varo-agent-foreground)] hover:bg-[var(--varo-agent-surface-strong)]` function partMark(status: AgentPartStatus) { - if (status === 'completed') { return 'bg-emerald-500' } - if (status === 'failed') { return 'bg-red-500' } - if (status === 'running') { return 'agent-ui__pulse bg-teal-600' } - return 'bg-slate-300' + if (status === 'completed') { return 'bg-[var(--varo-agent-success)]' } + if (status === 'failed') { return 'bg-[var(--varo-agent-danger)]' } + if (status === 'running') { return 'agent-ui__pulse bg-[var(--varo-agent-primary)]' } + return 'bg-[var(--varo-agent-border-strong)]' +} + +function renderCheckIcon() { + return h('svg', { + 'fill': 'none', + 'stroke': 'currentColor', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': 2.2, + 'viewBox': '0 0 24 24', + 'width': 14, + 'height': 14, + 'aria-hidden': 'true', + }, [h('path', { d: 'm5 12 4 4L19 6' })]) } export const AgentLoading = defineComponent({ @@ -130,11 +144,11 @@ export const AgentLoading = defineComponent({ onMounted(start) onBeforeUnmount(stop) - return () => h('div', { class: 'flex min-h-12 items-center gap-3 text-slate-700', role: 'status' }, [ + return () => h('div', { class: 'agent-loading flex min-h-12 items-center gap-3 text-[var(--varo-agent-foreground)]', role: 'status' }, [ h('span', { 'class': 'agent-ui__loading flex h-6 w-8 items-center justify-center gap-1', 'aria-hidden': 'true' }, Array.from({ length: props.variant === 'grid' ? 6 : 3 }, (_, index) => - h('i', { class: 'h-1.5 w-1.5 rounded-full bg-teal-700', style: { animationDelay: `${index * 80}ms` } }))), + h('i', { class: 'h-1.5 w-1.5 rounded-full bg-[var(--varo-agent-primary)]', style: { animationDelay: `${index * 80}ms` } }))), h('span', { class: 'min-w-0 flex-1 truncate text-[13px] font-semibold' }, props.label), - h('span', { class: 'text-[11px] tabular-nums text-slate-400' }, `${elapsed.value.toFixed(1)}s`), + h('span', { class: 'text-[12px] tabular-nums text-[var(--varo-agent-muted)]' }, `${elapsed.value.toFixed(1)}s`), ]) }, }) @@ -151,10 +165,10 @@ export const AgentMessage = defineComponent({ 'class': ['agent-message flex w-full min-w-0 items-start gap-2.5', props.role === 'user' && 'justify-end'], 'data-role': props.role, }, [ - props.role !== 'user' ? h('span', { 'class': 'grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-teal-700 text-xs font-black text-white', 'aria-hidden': 'true' }, 'V') : null, + props.role !== 'user' ? h('span', { 'class': 'grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-[var(--varo-agent-primary)] text-xs font-black text-[var(--varo-agent-primary-foreground)]', 'aria-hidden': 'true' }, 'V') : null, h('div', { class: ['grid min-w-0 max-w-[82%] gap-1', props.role === 'user' && 'justify-items-end'] }, [ props.label || props.timestamp - ? h('header', { class: 'flex w-full items-center justify-between gap-3 px-1 text-[10px] text-slate-400' }, [ + ? h('header', { class: 'flex w-full items-center justify-between gap-3 px-1 text-[11px] text-[var(--varo-agent-muted)]' }, [ h('span', props.label || (props.role === 'assistant' ? 'Varo Agent' : props.role === 'user' ? '你' : '系统')), props.timestamp ? h('time', props.timestamp) : null, ]) @@ -163,8 +177,8 @@ export const AgentMessage = defineComponent({ class: [ 'min-w-11 max-w-full overflow-hidden break-words border px-3.5 py-2.5 shadow-sm', props.role === 'user' - ? 'rounded-[16px_4px_16px_16px] border-teal-700 bg-teal-700 text-white' - : 'rounded-[4px_16px_16px_16px] border-slate-200 bg-white text-slate-800', + ? 'rounded-[16px_4px_16px_16px] border-[var(--varo-agent-primary)] bg-[var(--varo-agent-primary)] text-[var(--varo-agent-primary-foreground)]' + : 'rounded-[4px_16px_16px_16px] border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] text-[var(--varo-agent-foreground)]', ], }, slots.default?.()), ]), @@ -188,7 +202,7 @@ export const AgentThinking = defineComponent({ const completed = computed(() => props.steps.filter(step => step.status === 'completed').length) return () => h('section', { - 'class': cn('agent-thinking overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm', props.className), + 'class': cn('agent-thinking overflow-hidden rounded-2xl border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] shadow-sm', props.className), 'data-open': String(currentOpen.value), }, [ h('button', { @@ -260,11 +274,11 @@ export const AgentToolChip = defineComponent({ tool: { type: Object as PropType, required: true }, }, setup(props) { - return () => h('span', { class: 'inline-flex max-w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-2.5 py-1.5' }, [ + return () => h('span', { class: 'inline-flex max-w-full items-center gap-2 rounded-xl border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] px-2.5 py-1.5' }, [ h('i', { class: ['h-2 w-2 flex-none rounded-full', partMark(props.tool.status)] }), h('span', { class: 'grid min-w-0' }, [ - h('strong', { class: 'truncate text-[11px] text-slate-700' }, props.tool.name), - props.tool.summary && !props.compact ? h('small', { class: 'truncate text-[10px] text-slate-400' }, props.tool.summary) : null, + h('strong', { class: 'truncate text-[12px] text-[var(--varo-agent-foreground)]' }, props.tool.name), + props.tool.summary && !props.compact ? h('small', { class: 'truncate text-[11px] text-[var(--varo-agent-muted)]' }, props.tool.summary) : null, ]), ]) }, @@ -278,16 +292,16 @@ export const AgentTaskList = defineComponent({ }, setup(props) { const completed = computed(() => props.tasks.filter(task => task.status === 'completed').length) - return () => h('section', { 'class': 'overflow-hidden rounded-2xl border border-slate-200 bg-white', 'aria-live': 'polite' }, [ - h('header', { class: 'flex min-h-11 items-center justify-between border-b border-slate-100 px-3.5' }, [ - h('strong', { class: 'text-[13px] text-slate-950' }, props.title), - h('span', { class: 'text-[11px] tabular-nums text-slate-400' }, `${completed.value}/${props.tasks.length}`), + return () => h('section', { 'class': 'overflow-hidden rounded-2xl border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)]', 'aria-live': 'polite' }, [ + h('header', { class: 'flex min-h-11 items-center justify-between border-b border-[var(--varo-agent-border)] px-3.5' }, [ + h('strong', { class: 'text-[13px] text-[var(--varo-agent-foreground)]' }, props.title), + h('span', { class: 'text-[12px] tabular-nums text-[var(--varo-agent-muted)]' }, `${completed.value}/${props.tasks.length}`), ]), - ...props.tasks.map((task, index) => h('div', { class: 'flex min-h-[50px] items-center gap-2.5 border-b border-slate-50 px-3.5 py-2 last:border-0', key: task.id }, [ - h('span', { class: ['grid h-6 w-6 flex-none place-items-center rounded-full border text-[10px] font-bold', task.status === 'completed' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 text-slate-500'] }, task.status === 'completed' ? '✓' : String(index + 1)), + ...props.tasks.map((task, index) => h('div', { class: 'flex min-h-[50px] items-center gap-2.5 border-b border-[var(--varo-agent-border)] px-3.5 py-2 last:border-0', key: task.id }, [ + h('span', { class: ['grid h-6 w-6 flex-none place-items-center rounded-full border text-[11px] font-bold', task.status === 'completed' ? 'border-[var(--varo-agent-success)] bg-[var(--varo-agent-success-soft)] text-[var(--varo-agent-success)]' : 'border-[var(--varo-agent-border)] text-[var(--varo-agent-text)]'] }, task.status === 'completed' ? renderCheckIcon() : String(index + 1)), h('span', { class: 'grid min-w-0 flex-1 gap-1' }, [ - h('span', { class: 'flex justify-between gap-2 text-xs font-semibold text-slate-700' }, [task.title, task.meta ? h('small', { class: 'text-slate-400' }, task.meta) : null]), - task.progress === undefined ? null : h('span', { class: 'h-1 overflow-hidden rounded-full bg-slate-100' }, [h('i', { class: 'block h-full bg-teal-600', style: { width: `${Math.min(100, Math.max(0, task.progress))}%` } })]), + h('span', { class: 'flex justify-between gap-2 text-xs font-semibold text-[var(--varo-agent-foreground)]' }, [task.title, task.meta ? h('small', { class: 'text-[var(--varo-agent-muted)]' }, task.meta) : null]), + task.progress === undefined ? null : h('span', { class: 'h-1 overflow-hidden rounded-full bg-[var(--varo-agent-fill)]' }, [h('i', { class: 'block h-full bg-[var(--varo-agent-primary)]', style: { width: `${Math.min(100, Math.max(0, task.progress))}%` } })]), ]), ])), ]) @@ -373,7 +387,7 @@ export const AgentComposerScope = defineComponent({ h('span', { class: 'text-[11px] tabular-nums text-[var(--varo-agent-muted)]' }, `${enabledCount.value} 个已启用`), ]), props.sources.length - ? h('div', { class: 'grid gap-1 p-2' }, props.sources.map(source => { + ? h('div', { class: 'grid gap-1 p-2' }, props.sources.map((source) => { const status = source.status ?? 'available' return h('div', { 'class': 'flex min-h-[58px] items-center gap-2.5 rounded-xl bg-[var(--varo-agent-surface-strong)] px-2.5 py-2', 'data-status': status, 'key': source.id }, [ h('i', { 'class': ['h-3 w-3 flex-none rounded-full', contextSourceStatusClasses[status]], 'aria-hidden': 'true' }), @@ -857,27 +871,68 @@ export const AgentApproval = defineComponent({ 'update:value': (_value: string) => true, }, setup(props, { emit, slots }) { - return () => h('section', { 'class': 'grid gap-3.5 rounded-2xl border border-amber-200 bg-gradient-to-br from-white to-amber-50 p-4 shadow-sm', 'role': 'group', 'aria-label': props.title }, [ - h('header', { class: 'flex items-start gap-3' }, [ - h('span', { class: 'grid h-9 w-9 flex-none place-items-center rounded-xl bg-amber-100 font-black text-amber-700' }, '!'), - h('span', { class: 'grid min-w-0 gap-0.5' }, [ - h('small', { class: 'font-extrabold tracking-widest text-amber-700' }, '需要你的确认'), - h('strong', { class: 'text-[15px] leading-snug text-amber-950' }, props.title), - props.description ? h('span', { class: 'text-xs leading-5 text-amber-800' }, props.description) : null, + return () => h('section', { 'class': 'agent-approval', 'role': 'group', 'aria-label': props.title }, [ + h('header', { class: 'agent-approval__header' }, [ + h('span', { 'class': 'agent-approval__icon', 'aria-hidden': 'true' }, [ + h('svg', { + 'fill': 'none', + 'stroke': 'currentColor', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': 1.8, + 'viewBox': '0 0 24 24', + }, [ + h('path', { d: 'm12 3-7 3v5c0 4.6 3 8.6 7 10 4-1.4 7-5.4 7-10V6z' }), + h('path', { d: 'M12 8v4 M12 16h.01' }), + ]), + ]), + h('span', { class: 'agent-approval__heading' }, [ + h('small', { class: 'agent-approval__eyebrow' }, '需要你的确认'), + h('strong', { class: 'agent-approval__title' }, props.title), + props.description + ? h('span', { class: 'agent-approval__description' }, props.description) + : null, ]), ]), props.choices.length > 0 - ? h('div', { class: 'grid gap-2' }, props.choices.map(choice => - h('label', { class: ['flex min-h-11 cursor-pointer items-center gap-2 rounded-xl border px-3 text-xs', choice.value === props.value ? 'border-teal-600 bg-teal-50 text-teal-900' : 'border-slate-200 bg-white text-slate-700', choice.disabled && 'cursor-not-allowed opacity-45'], key: choice.value }, [ - h('input', { checked: choice.value === props.value, disabled: choice.disabled, name: `agent-approval-${props.title}`, type: 'radio', value: choice.value, onChange: () => emit('update:value', choice.value) }), - h('span', { class: 'grid' }, [h('strong', choice.label), choice.description ? h('small', { class: 'text-slate-500' }, choice.description) : null]), + ? h('div', { class: 'agent-approval__choices' }, props.choices.map(choice => + h('label', { + 'class': 'agent-approval__choice', + 'data-disabled': String(Boolean(choice.disabled)), + 'data-selected': String(choice.value === props.value), + 'key': choice.value, + }, [ + h('input', { + class: 'agent-approval__radio', + checked: choice.value === props.value, + disabled: choice.disabled, + name: `agent-approval-${props.title}`, + type: 'radio', + value: choice.value, + onChange: () => emit('update:value', choice.value), + }), + h('span', { class: 'agent-approval__choice-copy' }, [ + h('strong', choice.label), + choice.description + ? h('small', choice.description) + : null, + ]), ]), )) : null, slots.default?.(), - h('footer', { class: 'flex justify-end gap-2' }, [ - h('button', { class: quietButton, type: 'button', onClick: () => emit('reject') }, props.rejectText), - h('button', { class: primaryButton, disabled: props.choices.length > 0 && !props.value, type: 'button', onClick: () => emit('approve', props.value) }, props.approveText), + h('footer', { class: 'agent-approval__footer' }, [ + h('button', { + class: 'agent-approval__reject', + type: 'button', + onClick: () => emit('reject'), + }, props.rejectText), + h('button', { + class: 'agent-approval__approve', + disabled: props.choices.length > 0 && !props.value, + type: 'button', + onClick: () => emit('approve', props.value), + }, props.approveText), ]), ]) }, @@ -894,10 +949,10 @@ export const AgentRecommendation = defineComponent({ emits: { accept: () => true }, setup(props, { emit, slots }) { const confidence = computed(() => Math.min(100, Math.max(0, props.confidence))) - return () => h('section', { class: 'grid gap-3 rounded-2xl border border-blue-100 bg-gradient-to-br from-white to-blue-50 p-4 shadow-sm' }, [ - h('header', { class: 'flex justify-between text-[10px] font-extrabold tracking-widest text-blue-700' }, [h('span', 'AGENT 建议'), h('span', `${confidence.value}%`)]), - h('strong', { class: 'text-[15px] text-slate-950' }, props.title), - props.description ? h('p', { class: 'm-0 text-xs leading-5 text-slate-600' }, props.description) : null, + return () => h('section', { class: 'agent-recommendation grid gap-3 rounded-2xl border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] p-4 shadow-[var(--varo-agent-shadow)]' }, [ + h('header', { class: 'flex justify-between text-[11px] font-extrabold tracking-widest text-[var(--varo-agent-primary)]' }, [h('span', 'AGENT 建议'), h('span', `${confidence.value}%`)]), + h('strong', { class: 'text-[15px] text-[var(--varo-agent-foreground)]' }, props.title), + props.description ? h('p', { class: 'm-0 text-xs leading-5 text-[var(--varo-agent-text)]' }, props.description) : null, h('span', { class: 'h-1.5 overflow-hidden rounded-full bg-blue-100' }, [h('i', { class: 'block h-full rounded-full bg-blue-600', style: { width: `${confidence.value}%` } })]), slots.default?.(), h('footer', { class: 'flex justify-end gap-2' }, [slots.secondary?.(), h('button', { class: primaryButton, type: 'button', onClick: () => emit('accept') }, props.acceptText)]), @@ -911,7 +966,7 @@ export const AgentPromptSuggestions = defineComponent({ emits: { select: (_value: string) => true }, setup(props, { emit }) { return () => h('div', { class: 'flex max-w-full gap-2 overflow-x-auto pb-1' }, props.suggestions.map(suggestion => - h('button', { class: 'min-h-9 flex-none rounded-full border border-slate-200 bg-white px-3 text-[11px] font-semibold text-slate-600 hover:border-teal-300 hover:text-teal-800', key: suggestion, type: 'button', onClick: () => emit('select', suggestion) }, suggestion), + h('button', { class: 'min-h-9 flex-none rounded-full border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] px-3 text-[12px] font-semibold text-[var(--varo-agent-text)] hover:border-[var(--varo-agent-primary)] hover:text-[var(--varo-agent-primary)]', key: suggestion, type: 'button', onClick: () => emit('select', suggestion) }, suggestion), )) }, }) @@ -919,6 +974,7 @@ export const AgentPromptSuggestions = defineComponent({ export const AgentComposer = defineComponent({ name: 'AgentComposer', props: { + ariaLabel: { type: String, default: 'Agent 输入' }, busy: Boolean, maxLength: { type: Number, default: 4000 }, modelValue: { type: String, default: '' }, @@ -935,21 +991,22 @@ export const AgentComposer = defineComponent({ if (!normalized || props.busy) { return } emit('submit', normalized) } - return () => h('div', { class: 'grid w-full min-w-0 gap-2.5' }, [ + return () => h('div', { class: 'agent-composer grid w-full min-w-0 gap-2.5' }, [ h(AgentPromptSuggestions, { suggestions: props.suggestions, onSelect: submit }), - h('div', { class: 'flex min-h-14 min-w-0 items-center gap-2 rounded-[18px] border border-slate-200 bg-white p-2 shadow-lg' }, [ + h('div', { class: 'agent-composer__shell flex min-h-14 min-w-0 items-center gap-2 rounded-[18px] border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] p-2 shadow-lg' }, [ slots.leading?.(), h('textarea', { - class: 'max-h-40 min-h-10 min-w-0 flex-1 resize-none border-0 bg-transparent px-2 py-2.5 text-sm leading-5 text-slate-900 outline-none placeholder:text-slate-400', - disabled: props.busy, - maxlength: props.maxLength, - placeholder: props.placeholder, - rows: 1, - value: props.modelValue, - onInput: (event: Event) => { + 'aria-label': props.ariaLabel, + 'class': 'max-h-40 min-h-10 min-w-0 flex-1 resize-none border-0 bg-transparent px-2 py-2.5 text-sm leading-5 text-[var(--varo-agent-foreground)] outline-none placeholder:text-[var(--varo-agent-muted)]', + 'disabled': props.busy, + 'maxlength': props.maxLength, + 'placeholder': props.placeholder, + 'rows': 1, + 'value': props.modelValue, + 'onInput': (event: Event) => { if (event.target instanceof HTMLTextAreaElement) { emit('update:modelValue', event.target.value) } }, - onKeydown: (event: KeyboardEvent) => { + 'onKeydown': (event: KeyboardEvent) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() submit() @@ -961,7 +1018,7 @@ export const AgentComposer = defineComponent({ 'button', { 'aria-label': props.busy ? 'Agent 正在处理' : '发送', - 'class': 'grid h-10 w-10 flex-none place-items-center self-center rounded-full bg-teal-700 text-lg font-bold text-white shadow-sm transition-transform active:scale-95 disabled:opacity-45', + 'class': 'grid h-10 w-10 flex-none place-items-center self-center rounded-full bg-[var(--varo-agent-primary)] text-lg font-bold text-[var(--varo-agent-primary-foreground)] shadow-sm transition-transform active:translate-y-px disabled:opacity-45', 'disabled': props.busy || !props.modelValue.trim(), 'type': 'button', 'onClick': () => submit(), @@ -976,7 +1033,20 @@ export const AgentComposer = defineComponent({ style: { animationDelay: `${index * 90}ms` }, })), ) - : h('span', { 'aria-hidden': 'true', 'class': '-translate-y-px' }, '↑'), + : h('svg', { + 'fill': 'none', + 'stroke': 'currentColor', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': 1.9, + 'viewBox': '0 0 24 24', + 'width': 20, + 'height': 20, + 'aria-hidden': 'true', + }, [ + h('path', { d: 'm4 12 16-8-5 16-3.5-6.5L4 12Z' }), + h('path', { d: 'M11.5 13.5 20 4' }), + ]), ), ]), ]) @@ -995,10 +1065,10 @@ export const AgentStream = defineComponent({ }, emits: { retry: () => true }, setup(props, { emit, slots }) { - return () => h('div', { 'class': cn('agent-stream text-sm leading-7 text-slate-800', props.className), 'data-status': props.status, 'aria-live': 'polite' }, [ + return () => h('div', { 'class': cn('agent-stream text-sm leading-7 text-[var(--varo-agent-foreground)]', props.className), 'data-status': props.status, 'aria-live': 'polite' }, [ h(AgentMarkdown, { content: props.content, final: props.final || props.status === 'completed' }), - props.cursor && props.status === 'streaming' ? h('i', { class: 'agent-ui__cursor ml-1 inline-block h-[1.15em] w-0.5 rounded-full bg-teal-700 align-[-.18em]' }) : null, - props.status === 'failed' ? h('div', { class: 'mt-2.5 flex min-h-11 items-center justify-between gap-3 rounded-xl bg-red-50 px-3 py-2.5 text-xs text-red-700', role: 'alert' }, [h('span', props.error || '生成失败,请重试'), h('button', { class: quietButton, type: 'button', onClick: () => emit('retry') }, '重试')]) : null, + props.cursor && props.status === 'streaming' ? h('i', { class: 'agent-ui__cursor ml-1 inline-block h-[1.15em] w-0.5 rounded-full bg-[var(--varo-agent-primary)] align-[-.18em]' }) : null, + props.status === 'failed' ? h('div', { class: 'mt-2.5 flex min-h-11 items-center justify-between gap-3 rounded-xl bg-[var(--varo-agent-danger-soft)] px-3 py-2.5 text-xs text-[var(--varo-agent-danger)]', role: 'alert' }, [h('span', props.error || '生成失败,请重试'), h('button', { class: quietButton, type: 'button', onClick: () => emit('retry') }, '重试')]) : null, props.status === 'completed' ? h('div', { class: 'mt-3 flex flex-wrap gap-2' }, slots.actions?.()) : null, ]) }, @@ -1039,13 +1109,13 @@ export const AgentArtifact = defineComponent({ h('article', { class: 'overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 shadow-lg' }, [ h('header', { class: 'flex min-h-14 items-center justify-between gap-3 border-b border-slate-800 bg-slate-900 px-4' }, [ h('span', { class: 'grid min-w-0 gap-0.5' }, [ - h('small', { class: 'text-[9px] font-black uppercase tracking-[0.16em] text-teal-300' }, props.artifact.kind || 'artifact'), + h('small', { class: 'text-[10px] font-black uppercase tracking-[0.16em] text-teal-300' }, props.artifact.kind || 'artifact'), h('strong', { class: 'truncate text-xs text-slate-100' }, props.artifact.title), ]), h( 'button', { - class: 'min-h-8 flex-none rounded-lg border border-slate-700 bg-slate-800 px-3 text-[10px] font-bold text-slate-200 transition-colors hover:border-teal-500 hover:text-white', + class: 'min-h-8 flex-none rounded-lg border border-slate-700 bg-slate-800 px-3 text-[11px] font-bold text-slate-200 transition-colors hover:border-teal-500 hover:text-white', type: 'button', onClick: () => emit('open', props.artifact), }, @@ -1055,7 +1125,7 @@ export const AgentArtifact = defineComponent({ props.artifact.content ? h( 'pre', - { class: 'm-0 max-h-72 overflow-auto whitespace-pre-wrap break-words bg-slate-950 px-4 py-3.5 font-mono text-[11px] leading-5 text-slate-200' }, + { class: 'm-0 max-h-72 overflow-auto whitespace-pre-wrap break-words bg-slate-950 px-4 py-3.5 font-mono text-[12px] leading-5 text-slate-200' }, props.artifact.content, ) : null, @@ -1072,13 +1142,13 @@ export const AgentSourceList = defineComponent({ emits: { open: (_source: AgentSourceItem) => true }, setup(props, { emit }) { return () => - h('section', { class: 'grid gap-2.5 rounded-2xl border border-slate-200 bg-slate-50 p-3 shadow-sm' }, [ + h('section', { class: 'grid gap-2.5 rounded-2xl border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface-strong)] p-3 shadow-sm' }, [ h('header', { class: 'flex items-end justify-between gap-3 px-0.5' }, [ h('span', { class: 'grid gap-0.5' }, [ - h('small', { class: 'text-[9px] font-black uppercase tracking-[0.16em] text-teal-700' }, 'Sources'), - h('strong', { class: 'text-xs text-slate-800' }, props.title), + h('small', { class: 'text-[10px] font-black uppercase tracking-[0.16em] text-[var(--varo-agent-primary)]' }, 'Sources'), + h('strong', { class: 'text-xs text-[var(--varo-agent-foreground)]' }, props.title), ]), - h('small', { class: 'rounded-full bg-white px-2 py-1 text-[9px] font-bold text-slate-500 ring-1 ring-slate-200' }, `${props.sources.length} refs`), + h('small', { class: 'rounded-full bg-[var(--varo-agent-surface)] px-2 py-1 text-[10px] font-bold text-[var(--varo-agent-text)] ring-1 ring-slate-200' }, `${props.sources.length} refs`), ]), h( 'div', @@ -1087,7 +1157,7 @@ export const AgentSourceList = defineComponent({ h( 'a', { - class: 'group flex min-h-14 items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 text-xs text-slate-700 shadow-sm transition-all hover:-translate-y-px hover:border-teal-300 hover:shadow-md', + class: 'group flex min-h-14 items-center gap-3 rounded-xl border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] px-3 text-xs text-[var(--varo-agent-foreground)] shadow-sm transition-all hover:-translate-y-px hover:border-[var(--varo-agent-primary)] hover:shadow-md', href: source.url, key: source.id, rel: 'noreferrer noopener', @@ -1095,12 +1165,12 @@ export const AgentSourceList = defineComponent({ onClick: () => emit('open', source), }, [ - h('span', { class: 'grid h-8 w-8 flex-none place-items-center rounded-xl bg-teal-50 text-[11px] font-black text-teal-700' }, String(index + 1).padStart(2, '0')), + h('span', { class: 'grid h-8 w-8 flex-none place-items-center rounded-xl bg-[var(--varo-agent-primary-soft)] text-[12px] font-black text-[var(--varo-agent-primary)]' }, String(index + 1).padStart(2, '0')), h('span', { class: 'grid min-w-0 flex-1 gap-0.5' }, [ - h('strong', { class: 'truncate text-[11px] text-slate-800' }, source.title), - h('small', { class: 'truncate text-[9px] text-slate-400' }, source.domain || source.description || source.url), + h('strong', { class: 'truncate text-[12px] text-[var(--varo-agent-foreground)]' }, source.title), + h('small', { class: 'truncate text-[10px] text-[var(--varo-agent-muted)]' }, source.domain || source.description || source.url), ]), - h('span', { 'aria-hidden': 'true', 'class': 'text-xs text-slate-300 transition-colors group-hover:text-teal-600' }, '↗'), + h('span', { 'aria-hidden': 'true', 'class': 'text-xs text-slate-300 transition-colors group-hover:text-[var(--varo-agent-primary)]' }, '↗'), ], ), ), @@ -1115,29 +1185,29 @@ export const AgentAttachmentList = defineComponent({ emits: { remove: (_item: AgentAttachmentItem) => true }, setup(props, { emit }) { return () => - h('section', { class: 'grid gap-2.5 rounded-2xl border border-slate-200 bg-slate-50 p-3 shadow-sm' }, [ + h('section', { class: 'grid gap-2.5 rounded-2xl border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface-strong)] p-3 shadow-sm' }, [ h('header', { class: 'flex items-end justify-between gap-3 px-0.5' }, [ h('span', { class: 'grid gap-0.5' }, [ - h('small', { class: 'text-[9px] font-black uppercase tracking-[0.16em] text-teal-700' }, 'Files'), - h('strong', { class: 'text-xs text-slate-800' }, '附件'), + h('small', { class: 'text-[10px] font-black uppercase tracking-[0.16em] text-[var(--varo-agent-primary)]' }, 'Files'), + h('strong', { class: 'text-xs text-[var(--varo-agent-foreground)]' }, '附件'), ]), - h('small', { class: 'text-[9px] font-bold text-slate-400' }, `${props.attachments.length} items`), + h('small', { class: 'text-[10px] font-bold text-[var(--varo-agent-muted)]' }, `${props.attachments.length} items`), ]), h( 'div', { class: 'grid gap-2' }, props.attachments.map(item => - h('article', { class: 'flex min-h-14 min-w-0 items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 shadow-sm', key: item.id }, [ + h('article', { class: 'flex min-h-14 min-w-0 items-center gap-3 rounded-xl border border-[var(--varo-agent-border)] bg-[var(--varo-agent-surface)] px-3 shadow-sm', key: item.id }, [ item.previewUrl ? h('img', { alt: '', class: 'h-9 w-9 flex-none rounded-xl object-cover', src: item.previewUrl }) : h( 'i', - { class: 'grid h-9 w-9 flex-none place-items-center rounded-xl bg-slate-900 text-[9px] font-black not-italic text-white' }, + { class: 'grid h-9 w-9 flex-none place-items-center rounded-xl bg-slate-900 text-[10px] font-black not-italic text-white' }, item.name.split('.').pop()?.slice(0, 4).toUpperCase() || 'FILE', ), h('span', { class: 'grid min-w-0 flex-1 gap-0.5' }, [ - h('strong', { class: 'truncate text-[11px] text-slate-800' }, item.name), - h('small', { class: 'text-[9px] text-slate-400' }, [item.size, item.mimeType].filter(Boolean).join(' · ')), + h('strong', { class: 'truncate text-[12px] text-[var(--varo-agent-foreground)]' }, item.name), + h('small', { class: 'text-[10px] text-[var(--varo-agent-muted)]' }, [item.size, item.mimeType].filter(Boolean).join(' · ')), ]), h( 'button', @@ -1205,4 +1275,13 @@ export const AgentEventRenderer = defineComponent({ export { AgentMarkdown } export * from './advanced' +export { default as AgentRagPipeline } from './AgentRagPipeline.vue' +export type { + AgentRagAnswerPart, + AgentRagPipelineProps, + AgentRagSource, + AgentRagSourceTone, + AgentRagStageId, + AgentRagStep, +} from './rag-pipeline' export type { AgentStreamSnapshot, AgentStreamStatus, AgentToolPart } from '@varo-ui/ai' diff --git a/apps/playground-h5/src/components/agent-ui/rag-icons.ts b/apps/playground-h5/src/components/agent-ui/rag-icons.ts new file mode 100644 index 00000000..d7336904 --- /dev/null +++ b/apps/playground-h5/src/components/agent-ui/rag-icons.ts @@ -0,0 +1,9 @@ +import type { AgentRagStageId } from './rag-pipeline' + +export const agentRagIcons: Record = { + query: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTcgNGgxMGEzIDMgMCAwIDEgMyAzdjdhMyAzIDAgMCAxLTMgM2gtNmwtNCAzdi0zYTMgMyAwIDAgMS0zLTNWN2EzIDMgMCAwIDEgMy0zWiIvPjxwYXRoIGQ9Ik05LjUgOWEyLjUgMi41IDAgMCAxIDUgLjVjMCAxLjUtMi41IDEuNS0yLjUgM00xMiAxNC41aC4wMSIvPjwvc3ZnPg==', + embed: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTQgOGgxNk00IDE2aDE2TTkgNCA3IDIwTTE3IDRsLTIgMTYiLz48L3N2Zz4=', + retrieve: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTAuNSIgY3k9IjEwLjUiIHI9IjYuNSIvPjxwYXRoIGQ9Im0xNiAxNiA0IDQiLz48L3N2Zz4=', + assemble: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTcgM2g3bDUgNXYxM0g1VjNoMlpNMTQgM3Y2aDVNOCAxM2g4TTggMTdoNiIvPjwvc3ZnPg==', + generate: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibTEyIDMgMi4zIDYuN0wyMSAxMmwtNi43IDIuM0wxMiAyMWwtMi4zLTYuN0wzIDEybDYuNy0yLjNMMTIgM1pNMjAgMnY0TTE4IDRoNCIvPjwvc3ZnPg==', +} diff --git a/apps/playground-h5/src/components/agent-ui/rag-pipeline.ts b/apps/playground-h5/src/components/agent-ui/rag-pipeline.ts new file mode 100644 index 00000000..c5be7a0e --- /dev/null +++ b/apps/playground-h5/src/components/agent-ui/rag-pipeline.ts @@ -0,0 +1,81 @@ +import type { ClassValue } from '../../lib/cn' +import type { AgentAdvancedStatus } from './advanced-types' + +export type AgentRagStageId = 'query' | 'embed' | 'retrieve' | 'assemble' | 'generate' +export type AgentRagSourceTone = 'blue' | 'violet' | 'rose' + +export interface AgentRagStep { + id: AgentRagStageId + status: AgentAdvancedStatus + detail?: string + durationMs?: number +} + +export interface AgentRagSource { + id: string + title: string + excerpt?: string + score?: number + tokens?: number + tone?: AgentRagSourceTone +} + +export type AgentRagAnswerPart + = | { id: string, type: 'text', text: string } + | { id: string, type: 'citation', sourceId: string } + +export interface AgentRagPipelineProps { + className?: ClassValue + title?: string + query?: string + steps?: readonly AgentRagStep[] + sources?: readonly AgentRagSource[] + answer?: readonly AgentRagAnswerPart[] + elapsedMs?: number + reducedMotion?: boolean +} + +export const agentRagStages = [ + { id: 'query', label: '理解问题', description: '确认本次查询与来源范围' }, + { id: 'embed', label: '向量编码', description: '将问题转换为检索表示' }, + { id: 'retrieve', label: '检索来源', description: '查找与问题相关的片段' }, + { id: 'assemble', label: '组装上下文', description: '将来源片段组织为上下文' }, + { id: 'generate', label: '引用回答', description: '生成可追溯到来源的回答' }, +] as const + +const statusLabels: Record = { + waiting: '等待', + running: '处理中', + completed: '已完成', + failed: '失败', +} + +export function resolveRagSteps(steps: readonly AgentRagStep[]) { + const byId = new Map() + for (const step of steps) { + if (byId.has(step.id)) { throw new Error(`Duplicate RAG stage: ${step.id}`) } + byId.set(step.id, step) + } + return agentRagStages.map((definition) => { + const step = byId.get(definition.id) + const status = step?.status ?? 'waiting' + return { + ...definition, + status, + detail: step?.detail ?? definition.description, + statusLabel: statusLabels[status], + durationLabel: step?.durationMs === undefined ? '' : `${Math.round(step.durationMs)} ms`, + } + }) +} + +const sourceTones = ['blue', 'violet', 'rose'] as const + +export function getRagSourceTone(source: AgentRagSource): AgentRagSourceTone { + if (source.tone) { return source.tone } + let hash = 0 + for (let index = 0; index < source.id.length; index += 1) { + hash = (Math.imul(hash, 31) + source.id.charCodeAt(index)) | 0 + } + return sourceTones[(hash >>> 0) % 3]! +} diff --git a/apps/playground-h5/src/components/agent-workspace/AgentWorkspaceDemo.vue b/apps/playground-h5/src/components/agent-workspace/AgentWorkspaceDemo.vue index dc515b5f..2eb7b689 100644 --- a/apps/playground-h5/src/components/agent-workspace/AgentWorkspaceDemo.vue +++ b/apps/playground-h5/src/components/agent-workspace/AgentWorkspaceDemo.vue @@ -1,48 +1,26 @@ @@ -64,44 +48,59 @@ function submit(value: string) {
-

Grounded Agent Workspace

+

+ Grounded Agent Workspace +

Context, execution, and evidence stay visible

- - - + + +
-

{{ activity }}

+

+ {{ activity }} +

+ > + +
@@ -161,8 +160,8 @@ function submit(value: string) { } .agent-workspace-demo__activity { - margin: 0; padding: 10px 12px; + margin: 0; font-size: 12px; color: var(--varo-agent-text); background: var(--varo-agent-fill); @@ -171,8 +170,8 @@ function submit(value: string) { @media (max-width: 720px) { .agent-workspace-demo__header { - align-items: stretch; flex-direction: column; + align-items: stretch; } .agent-workspace-demo__placements { diff --git a/apps/playground-h5/src/components/blocks/agent-workspace.vue b/apps/playground-h5/src/components/blocks/agent-workspace.vue index 9e20bf76..f8f38ce4 100644 --- a/apps/playground-h5/src/components/blocks/agent-workspace.vue +++ b/apps/playground-h5/src/components/blocks/agent-workspace.vue @@ -116,23 +116,25 @@ function forwardSourceToggle(source: AgentContextSource, enabled: boolean) { @pin="emit('pinVersion', $event)" @select="emit('selectVersion', $event)" /> - - - - + + + + + +