From 7d3a994057f91f0c121d1ab48336f4cdeccb78f5 Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Tue, 5 May 2026 16:55:27 +0800 Subject: [PATCH 1/3] init: chat kit commit --- .github/workflows/auto-publish.yml | 6 +- .github/workflows/dispatch-publish.yml | 6 +- .github/workflows/pr-ci-build.yml | 5 +- .github/workflows/pr-ci-publish-packages.yml | 1 + package.json | 3 +- packages/chat/README.md | 190 +++++ packages/chat/package.json | 53 ++ packages/chat/src/components/ChatFooter.vue | 14 + packages/chat/src/components/ChatHeader.vue | 122 ++++ packages/chat/src/components/ChatLayout.vue | 71 ++ .../chat/src/components/ChatMessageList.vue | 159 +++++ packages/chat/src/components/ChatSender.vue | 200 ++++++ packages/chat/src/components/ChatWelcome.vue | 43 ++ .../attachments/ChatAttachments.vue | 36 + .../chat/src/components/attachments/index.ts | 3 + .../attachments/useChatAttachments.ts | 90 +++ .../src/components/feedback/ChatFeedback.vue | 165 +++++ .../components/feedback/ChatUsagePanel.vue | 123 ++++ .../components/feedback/actions/copyAction.ts | 32 + .../components/feedback/actions/editAction.ts | 36 + .../src/components/feedback/actions/index.ts | 3 + .../feedback/actions/refreshAction.ts | 54 ++ .../chat/src/components/feedback/index.ts | 3 + .../components/feedback/useChatFeedback.ts | 253 +++++++ .../feedback/useFeedbackVisibility.ts | 109 +++ .../src/components/feedback/useUsageInfo.ts | 46 ++ .../src/components/history/ChatHistory.vue | 39 ++ .../components/history/ChatHistoryContent.vue | 15 + .../components/history/ChatHistoryList.vue | 136 ++++ .../history/ChatHistoryManageButton.vue | 77 ++ .../history/ChatHistoryNewSession.vue | 66 ++ .../components/history/ChatHistoryPanel.vue | 180 +++++ .../components/history/ChatHistorySearch.vue | 49 ++ .../components/history/ChatHistoryToolbar.vue | 29 + packages/chat/src/components/history/index.ts | 8 + .../src/components/history/useHistoryState.ts | 33 + packages/chat/src/components/index.ts | 11 + .../chat/src/components/mcp/ChatMcpPanel.vue | 302 ++++++++ .../chat/src/components/mcp/McpTrigger.vue | 77 ++ packages/chat/src/components/mcp/index.ts | 4 + .../chat/src/components/mcp/useMcpManager.ts | 184 +++++ .../model-selector/ModelSelector.vue | 169 +++++ .../src/components/model-selector/index.ts | 4 + .../model-selector/useFloatingDropdown.ts | 91 +++ .../model-selector/useKeyboardNavigation.ts | 126 ++++ .../model-selector/useModelSelector.ts | 71 ++ .../page-regions/ChatDefaultBodyRegion.vue | 73 ++ .../page-regions/ChatDefaultFooterRegion.vue | 74 ++ .../page-regions/ChatDefaultHeaderRegion.vue | 35 + .../page-regions/ChatPageContent.vue | 95 +++ .../chat/src/components/page-regions/index.ts | 4 + .../renderers/AttachmentsRenderer.vue | 43 ++ .../renderers/EditInputRenderer.vue | 238 +++++++ .../components/renderers/ErrorRenderer.vue | 99 +++ .../renderers/MarkStreamRenderer.vue | 154 ++++ .../components/renderers/ToolCallRenderer.vue | 109 +++ .../renderers/ToolCallsRenderer.vue | 21 + .../chat/src/components/renderers/index.ts | 6 + .../shared/ConditionalThemeProvider.vue | 34 + .../src/components/useDefaultBubbleConfig.ts | 86 +++ packages/chat/src/components/useSlotFilter.ts | 15 + .../workspace/ChatWorkspaceLayout.vue | 231 ++++++ .../workspace/ChatWorkspaceLeftSheet.vue | 79 +++ .../workspace/ChatWorkspaceRightEmpty.vue | 63 ++ .../workspace/ChatWorkspaceRightPanel.vue | 121 ++++ .../workspace/ChatWorkspaceRightSheet.vue | 123 ++++ .../workspace/ChatWorkspaceSidebar.vue | 25 + .../workspace/ChatWorkspaceSidebarRail.vue | 83 +++ .../workspace/ChatWorkspaceSidebarShell.vue | 146 ++++ .../components/workspace/WorkspaceShell.vue | 315 +++++++++ .../chat/src/components/workspace/index.ts | 9 + .../workspace/useWorkspaceRegion.ts | 60 ++ .../components/workspace/workspaceUtils.ts | 37 + .../chat/src/entry/RootBootstrapProvider.vue | 54 ++ packages/chat/src/entry/TrChat.vue | 36 + packages/chat/src/entry/TrChatPage.vue | 165 +++++ packages/chat/src/entry/TrChatProvider.vue | 69 ++ packages/chat/src/entry/TrChatRoot.vue | 36 + .../src/entry/createRootBootstrapState.ts | 295 ++++++++ packages/chat/src/entry/index.ts | 6 + packages/chat/src/index.ts | 113 +++ packages/chat/src/internal.ts | 9 + .../runtime/config/createRuntimeFromConfig.ts | 662 ++++++++++++++++++ packages/chat/src/runtime/config/index.ts | 2 + .../runtime/config/resolveProviderRuntime.ts | 50 ++ .../src/runtime/config/trchatConfigEntry.ts | 68 ++ .../useTrChatConfigRuntimeResolution.ts | 56 ++ .../chat/src/runtime/core/messageIdentity.ts | 65 ++ .../chat/src/runtime/core/normalizeRuntime.ts | 150 ++++ .../src/runtime/engine/chatMessageState.ts | 75 ++ .../src/runtime/engine/chatRenderMessages.ts | 55 ++ packages/chat/src/runtime/engine/index.ts | 24 + .../src/runtime/engine/useChatConversation.ts | 233 ++++++ .../chat/src/runtime/engine/useChatKit.ts | 389 ++++++++++ .../src/runtime/engine/useChatMessages.ts | 87 +++ .../chat/src/runtime/engine/useChatRequest.ts | 190 +++++ .../chat/src/runtime/features/featureTypes.ts | 100 +++ packages/chat/src/runtime/features/index.ts | 22 + .../chat/src/runtime/features/registry.ts | 272 +++++++ packages/chat/src/runtime/transport/index.ts | 1 + .../transport/openaiCompatibleTransport.ts | 227 ++++++ packages/chat/src/shared/attachments.ts | 86 +++ .../chat/src/shared/context/chatUiContext.ts | 409 +++++++++++ packages/chat/src/shared/context/index.ts | 146 ++++ packages/chat/src/shared/messages/index.ts | 111 +++ packages/chat/src/shared/utils/iconMap.ts | 48 ++ packages/chat/src/shared/utils/index.ts | 3 + packages/chat/src/shared/utils/props.ts | 8 + packages/chat/src/shared/utils/typeGuards.ts | 35 + packages/chat/src/styles/drawer.css | 33 + packages/chat/src/styles/index.css | 11 + packages/chat/src/styles/layout.css | 299 ++++++++ packages/chat/src/styles/mcp-trigger.css | 86 +++ packages/chat/src/styles/model-selector.css | 173 +++++ packages/chat/src/styles/tokens.css | 271 +++++++ packages/chat/src/types/component.ts | 345 +++++++++ packages/chat/src/types/config.ts | 130 ++++ packages/chat/src/types/core.ts | 175 +++++ packages/chat/src/types/index.ts | 124 ++++ packages/chat/src/types/message.ts | 75 ++ packages/chat/src/types/model.ts | 9 + packages/chat/src/types/runtime.ts | 154 ++++ packages/chat/src/types/workspace.ts | 38 + packages/chat/tsconfig.json | 25 + packages/chat/vite.config.ts | 49 ++ packages/svgs/src/assets/bailian.svg | 1 + packages/svgs/src/assets/claude.svg | 1 + packages/svgs/src/assets/deepseek.svg | 1 + packages/svgs/src/assets/gemini.svg | 1 + packages/svgs/src/assets/info.svg | 7 + packages/svgs/src/assets/menu-open.svg | 14 + packages/svgs/src/assets/modelscope.svg | 1 + packages/svgs/src/assets/ollama.svg | 1 + packages/svgs/src/assets/openai.svg | 1 + packages/svgs/src/assets/openrouter.svg | 1 + packages/svgs/src/assets/panel-left-close.svg | 8 + .../svgs/src/assets/panel-right-close.svg | 8 + 137 files changed, 12265 insertions(+), 9 deletions(-) create mode 100644 packages/chat/README.md create mode 100644 packages/chat/package.json create mode 100644 packages/chat/src/components/ChatFooter.vue create mode 100644 packages/chat/src/components/ChatHeader.vue create mode 100644 packages/chat/src/components/ChatLayout.vue create mode 100644 packages/chat/src/components/ChatMessageList.vue create mode 100644 packages/chat/src/components/ChatSender.vue create mode 100644 packages/chat/src/components/ChatWelcome.vue create mode 100644 packages/chat/src/components/attachments/ChatAttachments.vue create mode 100644 packages/chat/src/components/attachments/index.ts create mode 100644 packages/chat/src/components/attachments/useChatAttachments.ts create mode 100644 packages/chat/src/components/feedback/ChatFeedback.vue create mode 100644 packages/chat/src/components/feedback/ChatUsagePanel.vue create mode 100644 packages/chat/src/components/feedback/actions/copyAction.ts create mode 100644 packages/chat/src/components/feedback/actions/editAction.ts create mode 100644 packages/chat/src/components/feedback/actions/index.ts create mode 100644 packages/chat/src/components/feedback/actions/refreshAction.ts create mode 100644 packages/chat/src/components/feedback/index.ts create mode 100644 packages/chat/src/components/feedback/useChatFeedback.ts create mode 100644 packages/chat/src/components/feedback/useFeedbackVisibility.ts create mode 100644 packages/chat/src/components/feedback/useUsageInfo.ts create mode 100644 packages/chat/src/components/history/ChatHistory.vue create mode 100644 packages/chat/src/components/history/ChatHistoryContent.vue create mode 100644 packages/chat/src/components/history/ChatHistoryList.vue create mode 100644 packages/chat/src/components/history/ChatHistoryManageButton.vue create mode 100644 packages/chat/src/components/history/ChatHistoryNewSession.vue create mode 100644 packages/chat/src/components/history/ChatHistoryPanel.vue create mode 100644 packages/chat/src/components/history/ChatHistorySearch.vue create mode 100644 packages/chat/src/components/history/ChatHistoryToolbar.vue create mode 100644 packages/chat/src/components/history/index.ts create mode 100644 packages/chat/src/components/history/useHistoryState.ts create mode 100644 packages/chat/src/components/index.ts create mode 100644 packages/chat/src/components/mcp/ChatMcpPanel.vue create mode 100644 packages/chat/src/components/mcp/McpTrigger.vue create mode 100644 packages/chat/src/components/mcp/index.ts create mode 100644 packages/chat/src/components/mcp/useMcpManager.ts create mode 100644 packages/chat/src/components/model-selector/ModelSelector.vue create mode 100644 packages/chat/src/components/model-selector/index.ts create mode 100644 packages/chat/src/components/model-selector/useFloatingDropdown.ts create mode 100644 packages/chat/src/components/model-selector/useKeyboardNavigation.ts create mode 100644 packages/chat/src/components/model-selector/useModelSelector.ts create mode 100644 packages/chat/src/components/page-regions/ChatDefaultBodyRegion.vue create mode 100644 packages/chat/src/components/page-regions/ChatDefaultFooterRegion.vue create mode 100644 packages/chat/src/components/page-regions/ChatDefaultHeaderRegion.vue create mode 100644 packages/chat/src/components/page-regions/ChatPageContent.vue create mode 100644 packages/chat/src/components/page-regions/index.ts create mode 100644 packages/chat/src/components/renderers/AttachmentsRenderer.vue create mode 100644 packages/chat/src/components/renderers/EditInputRenderer.vue create mode 100644 packages/chat/src/components/renderers/ErrorRenderer.vue create mode 100644 packages/chat/src/components/renderers/MarkStreamRenderer.vue create mode 100644 packages/chat/src/components/renderers/ToolCallRenderer.vue create mode 100644 packages/chat/src/components/renderers/ToolCallsRenderer.vue create mode 100644 packages/chat/src/components/renderers/index.ts create mode 100644 packages/chat/src/components/shared/ConditionalThemeProvider.vue create mode 100644 packages/chat/src/components/useDefaultBubbleConfig.ts create mode 100644 packages/chat/src/components/useSlotFilter.ts create mode 100644 packages/chat/src/components/workspace/ChatWorkspaceLayout.vue create mode 100644 packages/chat/src/components/workspace/ChatWorkspaceLeftSheet.vue create mode 100644 packages/chat/src/components/workspace/ChatWorkspaceRightEmpty.vue create mode 100644 packages/chat/src/components/workspace/ChatWorkspaceRightPanel.vue create mode 100644 packages/chat/src/components/workspace/ChatWorkspaceRightSheet.vue create mode 100644 packages/chat/src/components/workspace/ChatWorkspaceSidebar.vue create mode 100644 packages/chat/src/components/workspace/ChatWorkspaceSidebarRail.vue create mode 100644 packages/chat/src/components/workspace/ChatWorkspaceSidebarShell.vue create mode 100644 packages/chat/src/components/workspace/WorkspaceShell.vue create mode 100644 packages/chat/src/components/workspace/index.ts create mode 100644 packages/chat/src/components/workspace/useWorkspaceRegion.ts create mode 100644 packages/chat/src/components/workspace/workspaceUtils.ts create mode 100644 packages/chat/src/entry/RootBootstrapProvider.vue create mode 100644 packages/chat/src/entry/TrChat.vue create mode 100644 packages/chat/src/entry/TrChatPage.vue create mode 100644 packages/chat/src/entry/TrChatProvider.vue create mode 100644 packages/chat/src/entry/TrChatRoot.vue create mode 100644 packages/chat/src/entry/createRootBootstrapState.ts create mode 100644 packages/chat/src/entry/index.ts create mode 100644 packages/chat/src/index.ts create mode 100644 packages/chat/src/internal.ts create mode 100644 packages/chat/src/runtime/config/createRuntimeFromConfig.ts create mode 100644 packages/chat/src/runtime/config/index.ts create mode 100644 packages/chat/src/runtime/config/resolveProviderRuntime.ts create mode 100644 packages/chat/src/runtime/config/trchatConfigEntry.ts create mode 100644 packages/chat/src/runtime/config/useTrChatConfigRuntimeResolution.ts create mode 100644 packages/chat/src/runtime/core/messageIdentity.ts create mode 100644 packages/chat/src/runtime/core/normalizeRuntime.ts create mode 100644 packages/chat/src/runtime/engine/chatMessageState.ts create mode 100644 packages/chat/src/runtime/engine/chatRenderMessages.ts create mode 100644 packages/chat/src/runtime/engine/index.ts create mode 100644 packages/chat/src/runtime/engine/useChatConversation.ts create mode 100644 packages/chat/src/runtime/engine/useChatKit.ts create mode 100644 packages/chat/src/runtime/engine/useChatMessages.ts create mode 100644 packages/chat/src/runtime/engine/useChatRequest.ts create mode 100644 packages/chat/src/runtime/features/featureTypes.ts create mode 100644 packages/chat/src/runtime/features/index.ts create mode 100644 packages/chat/src/runtime/features/registry.ts create mode 100644 packages/chat/src/runtime/transport/index.ts create mode 100644 packages/chat/src/runtime/transport/openaiCompatibleTransport.ts create mode 100644 packages/chat/src/shared/attachments.ts create mode 100644 packages/chat/src/shared/context/chatUiContext.ts create mode 100644 packages/chat/src/shared/context/index.ts create mode 100644 packages/chat/src/shared/messages/index.ts create mode 100644 packages/chat/src/shared/utils/iconMap.ts create mode 100644 packages/chat/src/shared/utils/index.ts create mode 100644 packages/chat/src/shared/utils/props.ts create mode 100644 packages/chat/src/shared/utils/typeGuards.ts create mode 100644 packages/chat/src/styles/drawer.css create mode 100644 packages/chat/src/styles/index.css create mode 100644 packages/chat/src/styles/layout.css create mode 100644 packages/chat/src/styles/mcp-trigger.css create mode 100644 packages/chat/src/styles/model-selector.css create mode 100644 packages/chat/src/styles/tokens.css create mode 100644 packages/chat/src/types/component.ts create mode 100644 packages/chat/src/types/config.ts create mode 100644 packages/chat/src/types/core.ts create mode 100644 packages/chat/src/types/index.ts create mode 100644 packages/chat/src/types/message.ts create mode 100644 packages/chat/src/types/model.ts create mode 100644 packages/chat/src/types/runtime.ts create mode 100644 packages/chat/src/types/workspace.ts create mode 100644 packages/chat/tsconfig.json create mode 100644 packages/chat/vite.config.ts create mode 100644 packages/svgs/src/assets/bailian.svg create mode 100644 packages/svgs/src/assets/claude.svg create mode 100644 packages/svgs/src/assets/deepseek.svg create mode 100644 packages/svgs/src/assets/gemini.svg create mode 100644 packages/svgs/src/assets/info.svg create mode 100644 packages/svgs/src/assets/menu-open.svg create mode 100644 packages/svgs/src/assets/modelscope.svg create mode 100644 packages/svgs/src/assets/ollama.svg create mode 100644 packages/svgs/src/assets/openai.svg create mode 100644 packages/svgs/src/assets/openrouter.svg create mode 100644 packages/svgs/src/assets/panel-left-close.svg create mode 100644 packages/svgs/src/assets/panel-right-close.svg diff --git a/.github/workflows/auto-publish.yml b/.github/workflows/auto-publish.yml index a291194cb..106715f22 100644 --- a/.github/workflows/auto-publish.yml +++ b/.github/workflows/auto-publish.yml @@ -52,9 +52,9 @@ jobs: - name: Install dependencies run: pnpm i --no-frozen-lockfile - # 步骤7: 构建组件 - - name: Run Build Components - run: pnpm build + # 步骤7: 构建发布包 + - name: Run Build Packages + run: pnpm build:packages - name: Parse Publish tag id: parse_tag diff --git a/.github/workflows/dispatch-publish.yml b/.github/workflows/dispatch-publish.yml index bf60c4677..253b13447 100644 --- a/.github/workflows/dispatch-publish.yml +++ b/.github/workflows/dispatch-publish.yml @@ -70,9 +70,9 @@ jobs: - name: Install dependencies run: pnpm i --no-frozen-lockfile - # 步骤7: 构建组件 - - name: Run Build Components - run: pnpm build + # 步骤7: 构建发布包 + - name: Run Build Packages + run: pnpm build:packages # 步骤8: 发布组件到NPM - name: Publish components diff --git a/.github/workflows/pr-ci-build.yml b/.github/workflows/pr-ci-build.yml index 6e675b09a..294270416 100644 --- a/.github/workflows/pr-ci-build.yml +++ b/.github/workflows/pr-ci-build.yml @@ -39,8 +39,8 @@ jobs: - name: Install dependencies run: pnpm i --no-frozen-lockfile - - name: Build components - run: pnpm build + - name: Build packages + run: pnpm build:packages - name: Build docs run: pnpm -F docs build @@ -55,6 +55,7 @@ jobs: name: build-${{ github.event.pull_request.head.sha || github.sha }} path: | packages/components/dist + packages/chat/dist packages/kit/dist packages/svgs/dist docs/dist diff --git a/.github/workflows/pr-ci-publish-packages.yml b/.github/workflows/pr-ci-publish-packages.yml index 5f4eed1b8..e1837125d 100644 --- a/.github/workflows/pr-ci-publish-packages.yml +++ b/.github/workflows/pr-ci-publish-packages.yml @@ -51,6 +51,7 @@ jobs: run: | npx pkg-pr-new publish \ ./packages/components \ + ./packages/chat \ ./packages/kit \ ./packages/svgs \ --pnpm \ diff --git a/package.json b/package.json index 030f0fdcc..b4d21a56a 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ "postbuild:docs": "node scripts/copy-playground.js packages/playground/dist docs/dist/playground", "dev:playground": "pnpm -F @opentiny/tiny-robot-playground dev", "build:playground": "pnpm -F @opentiny/tiny-robot-playground build", - "build:deps": "pnpm -F @opentiny/tiny-robot-* -F \"!@opentiny/tiny-robot-playground\" build", + "build:deps": "pnpm -F @opentiny/tiny-robot-* -F \"!@opentiny/tiny-robot-playground\" -F \"!@opentiny/tiny-robot-chat\" build", "dev:components": "pnpm build:deps && pnpm -F @opentiny/tiny-robot dev", "build:components": "pnpm build:deps && pnpm -F @opentiny/tiny-robot build", + "build:packages": "pnpm build:components && pnpm -F @opentiny/tiny-robot-chat build", "dev:kit": "pnpm -F @opentiny/tiny-robot-kit dev", "build:kit": "pnpm -F @opentiny/tiny-robot-kit build", "build:svgs": "pnpm -F @opentiny/tiny-robot-svgs build", diff --git a/packages/chat/README.md b/packages/chat/README.md new file mode 100644 index 000000000..fcc516edb --- /dev/null +++ b/packages/chat/README.md @@ -0,0 +1,190 @@ +# @opentiny/tiny-robot-chat + +`@opentiny/tiny-robot-chat` 是 TinyRobot 提供的高级聊天 UI 包,基于 `@opentiny/tiny-robot`(基础组件库)和 `@opentiny/tiny-robot-kit`(数据层工具包)构建。 +它提供开箱即用的完整聊天页面,也支持逐步深入的白盒定制。 + +## 安装 + +```bash +pnpm add @opentiny/tiny-robot-chat +``` + +需要同时安装 peer dependencies: + +```bash +pnpm add @opentiny/tiny-robot @opentiny/tiny-robot-kit vue markstream-vue +``` + +## 基本用法 + +### 引入样式 + +```ts +import '@opentiny/tiny-robot-chat/style' +``` + +### 开箱即用(TrChat) + +传入一个 `TrChatConfig` 配置对象,即可渲染完整聊天页面: + +```vue + + + + + +``` + +## 三层入口 + +包提供三个官方入口层级,按定制深度递进: + +### 1. TrChat — 黑盒入口 + +适合快速接入,传入 `TrChatConfig` 即可。 + +```vue + +``` + +### 2. TrChat.Root + TrChat.Page — 白盒页面 + +适合需要自己创建 runtime 但保留官方页面组合的场景。 + +```vue + + + +``` + +使用 `createRuntimeFromConfig(config)` 从配置创建 runtime: + +```ts +import { createRuntimeFromConfig } from '@opentiny/tiny-robot-chat' + +const { runtime, ui, dispose } = createRuntimeFromConfig(config) +``` + +### 3. TrChat.Root + primitives — 细粒度组合 + +适合需要完全控制页面结构的场景,使用公开的 primitive 组件自由拼装。 + +```vue + + + + + + + + + + +``` + +### 高级:TrChat.Provider — 自定义 transport + +适合需要使用包的 UI 和聊天编排能力,但自带 transport / 数据层的团队。 + +```vue + + + + + + + + + +``` + +## 配置域 + +`TrChatConfig` 按功能域组织: + +| 配置域 | 职责 | +| --- | --- | +| `request` | 模型列表、默认模型、transport 配置 | +| `conversation` | 初始消息、持久化策略 | +| `ui` | 品牌、欢迎区、外观、内容布局、i18n 文案 | +| `sender` | 输入框占位符、模式、字数限制、语音 | +| `attachments` | 附件上传、列表配置 | +| `messages` | 消息操作、feedback、渲染器、transform | +| `history` | 历史记录启用、默认展开 | +| `workspace` | 工作区视图、左右区域配置 | +| `lifecycle` | `beforeSend` / `afterReceive` / `error` 钩子 | + +## 公开组件 + +| 组件 | 说明 | +| --- | --- | +| `TrChat` | 黑盒入口(含 `.Root` / `.Page` / `.Provider` 等子组件) | +| `TrChat.Layout` | 聊天布局容器 | +| `TrChat.WorkspaceLayout` | 工作区布局(含侧边栏) | +| `TrChat.Header` | 聊天头部 | +| `TrChat.Welcome` | 欢迎区 | +| `TrChat.MessageList` | 消息列表 | +| `TrChat.Footer` | 底部区域 | +| `TrChat.Sender` | 输入框 | +| `TrChat.Attachments` | 附件区 | +| `TrChat.History` | 历史记录 | +| `TrChatFeedback` | 消息反馈(独立导出) | +| `TrMcpTrigger` | MCP 触发器(独立导出) | + +## 验证命令 + +```bash +# 类型检查 +pnpm -F @opentiny/tiny-robot-chat type-check + +# 构建 +pnpm -F @opentiny/tiny-robot-chat build +``` + +## 目录结构 + +``` +src/ + entry/ # 入口组件(TrChat、TrChatRoot、TrChatPage、TrChatProvider) + components/ # UI 组件 + page-regions/ # 默认页面区域(Header/Body/Footer Region、ChatPageContent) + workspace/ # 工作区布局(WorkspaceShell、LeftSheet、RightSheet 等) + attachments/ # 附件 + feedback/ # 消息反馈 + history/ # 历史记录 + mcp/ # MCP 触发器和面板 + model-selector/ # 模型选择器 + renderers/ # 消息渲染器(Error、Edit、ToolCalls、Markdown 等) + shared/ # 共享组件(ConditionalThemeProvider) + runtime/ + config/ # createRuntimeFromConfig、config entry、provider resolution + core/ # normalizeRuntime、messageIdentity + engine/ # useChatKit、useChatConversation、useChatMessages、useChatRequest + transport/ # openaiCompatibleTransport + features/ # feature registry + shared/ + context/ # chatUiContext、injection keys + messages/ # i18n 文案(CHAT_MESSAGES) + utils/ # 工具函数 + types/ # 类型定义(component、config、runtime、message、workspace 等) + styles/ # CSS 样式 +``` diff --git a/packages/chat/package.json b/packages/chat/package.json new file mode 100644 index 000000000..dc230ac93 --- /dev/null +++ b/packages/chat/package.json @@ -0,0 +1,53 @@ +{ + "name": "@opentiny/tiny-robot-chat", + "version": "0.4.2", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./internal": { + "import": "./dist/internal.js", + "types": "./dist/internal.d.ts" + }, + "./style": { + "default": "./dist/style.css" + } + }, + "files": [ + "dist" + ], + "sideEffects": [ + "./dist/style.css" + ], + "scripts": { + "dev": "vue-tsc && vite build --watch", + "build": "vue-tsc && vite build", + "type-check": "vue-tsc --noEmit" + }, + "peerDependencies": { + "vue": "^3.3.11", + "markstream-vue": "^0.0.9-beta.0", + "@opentiny/tiny-robot": "workspace:*", + "@opentiny/tiny-robot-kit": "workspace:*" + }, + "dependencies": { + "@floating-ui/dom": "^1.7.5", + "@opentiny/tiny-robot-svgs": "workspace:*", + "@vueuse/core": "^13.1.0", + "markdown-it": "^14.1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.2", + "less": "^4.2.2", + "typescript": "^5.2.2", + "vite": "^5.0.8", + "vite-plugin-dts": "^4.5.3", + "vue": "^3.3.11", + "vue-tsc": "^2.2.8" + } +} diff --git a/packages/chat/src/components/ChatFooter.vue b/packages/chat/src/components/ChatFooter.vue new file mode 100644 index 000000000..d6082fa8a --- /dev/null +++ b/packages/chat/src/components/ChatFooter.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/chat/src/components/ChatHeader.vue b/packages/chat/src/components/ChatHeader.vue new file mode 100644 index 000000000..53289c8e6 --- /dev/null +++ b/packages/chat/src/components/ChatHeader.vue @@ -0,0 +1,122 @@ + + + + + + + + + + + + {{ resolvedTitle }} + + + + + + + + + + + + + diff --git a/packages/chat/src/components/ChatLayout.vue b/packages/chat/src/components/ChatLayout.vue new file mode 100644 index 000000000..fcabf5fa7 --- /dev/null +++ b/packages/chat/src/components/ChatLayout.vue @@ -0,0 +1,71 @@ + + + + + + + + + + + + + diff --git a/packages/chat/src/components/ChatMessageList.vue b/packages/chat/src/components/ChatMessageList.vue new file mode 100644 index 000000000..84d1f4685 --- /dev/null +++ b/packages/chat/src/components/ChatMessageList.vue @@ -0,0 +1,159 @@ + + + + + + + + + + + diff --git a/packages/chat/src/components/ChatSender.vue b/packages/chat/src/components/ChatSender.vue new file mode 100644 index 000000000..43a80c79c --- /dev/null +++ b/packages/chat/src/components/ChatSender.vue @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/ChatWelcome.vue b/packages/chat/src/components/ChatWelcome.vue new file mode 100644 index 000000000..0712956c9 --- /dev/null +++ b/packages/chat/src/components/ChatWelcome.vue @@ -0,0 +1,43 @@ + + + + + + emit('prompt-click', item.description ?? item.label)" + /> + + + + diff --git a/packages/chat/src/components/attachments/ChatAttachments.vue b/packages/chat/src/components/attachments/ChatAttachments.vue new file mode 100644 index 000000000..1f647855d --- /dev/null +++ b/packages/chat/src/components/attachments/ChatAttachments.vue @@ -0,0 +1,36 @@ + + + + + + + diff --git a/packages/chat/src/components/attachments/index.ts b/packages/chat/src/components/attachments/index.ts new file mode 100644 index 000000000..4728afbc3 --- /dev/null +++ b/packages/chat/src/components/attachments/index.ts @@ -0,0 +1,3 @@ +export { default as ChatAttachments } from './ChatAttachments.vue' +export { useChatAttachments } from './useChatAttachments' +export type { UseChatAttachmentsReturn } from './useChatAttachments' diff --git a/packages/chat/src/components/attachments/useChatAttachments.ts b/packages/chat/src/components/attachments/useChatAttachments.ts new file mode 100644 index 000000000..064142d8f --- /dev/null +++ b/packages/chat/src/components/attachments/useChatAttachments.ts @@ -0,0 +1,90 @@ +import { getCurrentScope, onScopeDispose, ref, type Ref } from 'vue' +import type { Attachment } from '@opentiny/tiny-robot' +import { detectFileType } from '@/shared/attachments' +import type { UseChatAttachmentsOptions } from '@/types' + +function normalizeAttachment(file: File): Attachment { + return { + rawFile: file, + url: URL.createObjectURL(file), + name: file.name, + size: file.size, + fileType: detectFileType(file), + status: 'success', + } +} + +export function useChatAttachments(options: UseChatAttachmentsOptions = {}) { + const items = ref([...(options.initialItems ?? [])]) + const ownedObjectUrls = new Set() + + function registerOwnedObjectUrl(item: Attachment) { + if (typeof item.url === 'string' && item.url) { + ownedObjectUrls.add(item.url) + } + } + + function revokeOwnedObjectUrl(item: Attachment) { + if (typeof item.url !== 'string' || !ownedObjectUrls.has(item.url)) { + return + } + + URL.revokeObjectURL(item.url) + ownedObjectUrls.delete(item.url) + } + + function revokeRemovedItems(nextItems: Attachment[]) { + const nextUrls = new Set( + nextItems.map((item) => item.url).filter((url): url is string => typeof url === 'string' && url.length > 0), + ) + + for (const item of items.value) { + if (typeof item.url === 'string' && !nextUrls.has(item.url)) { + revokeOwnedObjectUrl(item) + } + } + } + + function addFiles(files: File[]) { + const nextItems = files.map(normalizeAttachment) + nextItems.forEach(registerOwnedObjectUrl) + items.value.push(...nextItems) + } + + function setItems(nextItems: Attachment[]) { + revokeRemovedItems(nextItems) + items.value = [...nextItems] + } + + function removeItem(target: Attachment) { + revokeOwnedObjectUrl(target) + items.value = items.value.filter((item) => item !== target) + } + + function clear() { + items.value.forEach(revokeOwnedObjectUrl) + items.value = [] + } + + if (getCurrentScope()) { + onScopeDispose(() => { + clear() + }) + } + + return { + items, + addFiles, + setItems, + removeItem, + clear, + } +} + +export interface UseChatAttachmentsReturn { + items: Ref + addFiles: (files: File[]) => void + setItems: (items: Attachment[]) => void + removeItem: (item: Attachment) => void + clear: () => void +} diff --git a/packages/chat/src/components/feedback/ChatFeedback.vue b/packages/chat/src/components/feedback/ChatFeedback.vue new file mode 100644 index 000000000..21ad1913c --- /dev/null +++ b/packages/chat/src/components/feedback/ChatFeedback.vue @@ -0,0 +1,165 @@ + + + + + triggerAction(name, 'operations')" + /> + + + + diff --git a/packages/chat/src/components/feedback/ChatUsagePanel.vue b/packages/chat/src/components/feedback/ChatUsagePanel.vue new file mode 100644 index 000000000..4c789997c --- /dev/null +++ b/packages/chat/src/components/feedback/ChatUsagePanel.vue @@ -0,0 +1,123 @@ + + + + + + + + + {{ item.label }} + {{ item.value }} + + + + + + + diff --git a/packages/chat/src/components/feedback/actions/copyAction.ts b/packages/chat/src/components/feedback/actions/copyAction.ts new file mode 100644 index 000000000..2746e67d0 --- /dev/null +++ b/packages/chat/src/components/feedback/actions/copyAction.ts @@ -0,0 +1,32 @@ +import { useClipboard } from '@vueuse/core' +import type { ComputedRef } from 'vue' +import type { ChatMessageActionDefinition, ChatMessageActionRole, ChatRuntime } from '@/types' + +interface CopyActionOptions { + role: ChatMessageActionRole + label: string + content: ComputedRef + runtime: ChatRuntime | null + primaryMessageId: ComputedRef +} + +export function createCopyAction(options: CopyActionOptions): ChatMessageActionDefinition { + const { copy } = useClipboard() + const { role, label, content, runtime, primaryMessageId } = options + + return { + id: 'copy', + label, + icon: 'copy', + placement: 'actions', + roles: [role], + order: 100, + onClick: async () => { + if (runtime && primaryMessageId.value) { + await runtime.message.copy(primaryMessageId.value) + return + } + copy(content.value) + }, + } +} diff --git a/packages/chat/src/components/feedback/actions/editAction.ts b/packages/chat/src/components/feedback/actions/editAction.ts new file mode 100644 index 000000000..c1b2ea3e6 --- /dev/null +++ b/packages/chat/src/components/feedback/actions/editAction.ts @@ -0,0 +1,36 @@ +import { h } from 'vue' +import { TrIconButton } from '@opentiny/tiny-robot' +import { IconEditPen } from '@opentiny/tiny-robot-svgs' +import type { ChatMessageActionDefinition, ChatRuntime } from '@/types' + +interface EditActionFallbackRuntime { + startEditMessage: (messageIndex: number) => void +} + +interface EditActionOptions { + label: string + runtime: ChatRuntime | null + fallbackRuntime: EditActionFallbackRuntime | null +} + +export function createEditAction(options: EditActionOptions): ChatMessageActionDefinition { + const { label, runtime, fallbackRuntime } = options + + return { + id: 'edit', + label, + icon: h(TrIconButton, { icon: IconEditPen }), + placement: 'actions', + roles: ['user'], + order: 200, + onClick: (context) => { + if (runtime && context.messageId) { + runtime.message.startEdit(context.messageId) + return + } + if (fallbackRuntime && context.messageIndex !== undefined) { + fallbackRuntime.startEditMessage(context.messageIndex) + } + }, + } +} diff --git a/packages/chat/src/components/feedback/actions/index.ts b/packages/chat/src/components/feedback/actions/index.ts new file mode 100644 index 000000000..c087d476d --- /dev/null +++ b/packages/chat/src/components/feedback/actions/index.ts @@ -0,0 +1,3 @@ +export { createCopyAction } from './copyAction' +export { createEditAction } from './editAction' +export { createRefreshAction } from './refreshAction' diff --git a/packages/chat/src/components/feedback/actions/refreshAction.ts b/packages/chat/src/components/feedback/actions/refreshAction.ts new file mode 100644 index 000000000..2c7bd2659 --- /dev/null +++ b/packages/chat/src/components/feedback/actions/refreshAction.ts @@ -0,0 +1,54 @@ +import type { ComputedRef } from 'vue' +import type { ChatMessageActionDefinition, ChatErrorInfo, ChatRuntime } from '@/types' + +interface RefreshActionFallbackRuntime { + lastError: ComputedRef + retry: () => Promise + regenerate: (messageIndex?: number) => Promise +} + +interface RefreshActionOptions { + label: string + runtime: ChatRuntime | null + fallbackRuntime: RefreshActionFallbackRuntime | null + primaryMessageId: ComputedRef + isStreaming: ComputedRef + lastUserContent: ComputedRef +} + +export function createRefreshAction(options: RefreshActionOptions): ChatMessageActionDefinition { + const { label, runtime, fallbackRuntime, primaryMessageId, isStreaming, lastUserContent } = options + + return { + id: 'refresh', + label, + icon: 'refresh', + placement: 'actions', + roles: ['assistant'], + order: 200, + when: () => + Boolean((runtime && primaryMessageId.value) || (fallbackRuntime && !isStreaming.value && lastUserContent.value)), + onClick: async (context) => { + if (runtime && context.messageId) { + const viewState = runtime.message.getViewState(context.messageId) + if (viewState?.error?.retryable) { + await runtime.conversation.retry(context.messageId) + return + } + await runtime.conversation.regenerate(context.messageId) + return + } + + if (!fallbackRuntime || isStreaming.value || !lastUserContent.value) { + return + } + + if (fallbackRuntime.lastError.value?.retryable) { + await fallbackRuntime.retry() + return + } + + await fallbackRuntime.regenerate(context.messageIndex) + }, + } +} diff --git a/packages/chat/src/components/feedback/index.ts b/packages/chat/src/components/feedback/index.ts new file mode 100644 index 000000000..8ba8d2b26 --- /dev/null +++ b/packages/chat/src/components/feedback/index.ts @@ -0,0 +1,3 @@ +export { default as ChatFeedback } from './ChatFeedback.vue' +export { useChatFeedback } from './useChatFeedback' +export type { UseChatFeedbackOptions, UsageInfo } from './useChatFeedback' diff --git a/packages/chat/src/components/feedback/useChatFeedback.ts b/packages/chat/src/components/feedback/useChatFeedback.ts new file mode 100644 index 000000000..18128bdd2 --- /dev/null +++ b/packages/chat/src/components/feedback/useChatFeedback.ts @@ -0,0 +1,253 @@ +import { computed, type ComputedRef, type Ref } from 'vue' +import type { BubbleMessage, FeedbackProps } from '@opentiny/tiny-robot' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import { useResolvedChatMessages } from '@/shared/messages' +import { getChatRenderSourceMessage, unwrapChatRenderMessages } from '@/runtime/engine/chatRenderMessages' +import { ensureRuntimeMessageId } from '@/runtime/core/messageIdentity' +import type { + ChatErrorInfo, + ChatMessageActionContext, + ChatMessageActionDefinition, + ChatMessageActionsInput, + ChatMessageActionsMode, + ChatRuntime, + ChatStatus, +} from '@/types' +import { createCopyAction, createEditAction, createRefreshAction } from './actions' +import { useFeedbackVisibility } from './useFeedbackVisibility' +import { useUsageInfo } from './useUsageInfo' +import type { UsageInfo } from './useUsageInfo' + +export type { UsageInfo } + +interface ChatFeedbackFallbackRuntime { + activeConversationId: Readonly> + messages: ComputedRef + status: ComputedRef + lastError: ComputedRef + startEditMessage: (messageIndex: number) => void + isMessageEditing: (messageIndex: number) => boolean + retry: () => Promise + regenerate: (messageIndex?: number) => Promise +} + +export interface UseChatFeedbackOptions { + messages: BubbleMessage[] + messageIndexes: number[] + role?: string + enabled?: boolean + runtime?: ChatRuntime | null + fallbackRuntime?: ChatFeedbackFallbackRuntime | null + messageActions?: ChatMessageActionsInput + messageActionsMode?: ChatMessageActionsMode +} + +export function useRuntimeFeedbackEnabled(options: { enabled?: boolean; runtime?: ChatRuntime | null }) { + return computed(() => options.enabled ?? options.runtime?.message.config?.feedback?.enabled ?? true) +} + +export function useChatFeedback(options: UseChatFeedbackOptions) { + const { messages, messageIndexes, role, fallbackRuntime = null, runtime = null } = options + + // --- Base derived state --- + + const sourceMessages = computed(() => unwrapChatRenderMessages(messages as unknown as ChatMessage[])) + const chatMessages = useResolvedChatMessages() + + const runtimeActionMode = computed(() => runtime?.message.config?.actionMode) + const messageActionMode = computed( + () => options.messageActionsMode ?? runtimeActionMode.value ?? 'append', + ) + + const primaryMessage = computed(() => + getChatRenderSourceMessage(sourceMessages.value[sourceMessages.value.length - 1]), + ) + + const messageIds = computed(() => + sourceMessages.value + .map((message) => ensureRuntimeMessageId(message)) + .filter((messageId): messageId is string => Boolean(messageId)), + ) + + const primaryMessageId = computed(() => + primaryMessage.value ? ensureRuntimeMessageId(primaryMessage.value) : undefined, + ) + + const primaryViewState = computed(() => + runtime && primaryMessageId.value ? runtime.message.getViewState(primaryMessageId.value) : undefined, + ) + + const lastContent = computed(() => { + const last = [...sourceMessages.value].reverse().find((m) => m.role === 'assistant' || !m.role) + if (!last?.content) return '' + return typeof last.content === 'string' ? last.content : JSON.stringify(last.content) + }) + + const lastUserContent = computed(() => { + if (!fallbackRuntime) return '' + const allMessages = fallbackRuntime.messages.value + const firstIndex = messageIndexes[0] ?? 0 + for (let index = firstIndex - 1; index >= 0; index--) { + if (allMessages[index]?.role === 'user') { + const content = allMessages[index].content + return typeof content === 'string' ? content : '' + } + } + return '' + }) + + const userContent = computed(() => { + const userMessage = sourceMessages.value.find((m) => m.role === 'user') + if (!userMessage?.content) return '' + return typeof userMessage.content === 'string' ? userMessage.content : JSON.stringify(userMessage.content) + }) + + const isStreaming = computed(() => + primaryViewState.value + ? primaryViewState.value.status === 'streaming' || primaryViewState.value.status === 'pending' + : fallbackRuntime + ? fallbackRuntime.status.value === 'streaming' || fallbackRuntime.status.value === 'submitted' + : false, + ) + + const actionContext = computed(() => ({ + role, + messages: sourceMessages.value, + messageIds: messageIds.value, + messageIndexes, + message: primaryMessage.value as ChatMessage | undefined, + messageIndex: messageIndexes[messageIndexes.length - 1], + messageId: primaryMessageId.value, + runtime, + conversationId: runtime ? undefined : (fallbackRuntime?.activeConversationId.value ?? undefined), + })) + + // --- Visibility --- + + const runtimeFeedbackEnabled = useRuntimeFeedbackEnabled({ enabled: options.enabled, runtime }) + + const { shouldRender, isEditing, hasError, isPendingAssistantTurn } = useFeedbackVisibility({ + role, + messages: sourceMessages, + primaryMessage, + primaryMessageId, + messageIndexes, + runtime, + fallbackRuntime, + runtimeFeedbackEnabled, + }) + + // --- Built-in actions --- + + const builtInActions = computed(() => { + if (role === 'user') { + return [ + createCopyAction({ + role: 'user', + label: chatMessages.value.feedback.copy, + content: userContent, + runtime, + primaryMessageId, + }), + createEditAction({ label: chatMessages.value.feedback.edit, runtime, fallbackRuntime }), + ] + } + + if (role === 'assistant') { + return [ + createCopyAction({ + role: 'assistant', + label: chatMessages.value.feedback.copy, + content: lastContent, + runtime, + primaryMessageId, + }), + createRefreshAction({ + label: chatMessages.value.feedback.regenerate, + runtime, + fallbackRuntime, + primaryMessageId, + isStreaming, + lastUserContent, + }), + ] + } + + return [] + }) + + // --- Custom actions --- + + const customActions = computed(() => { + const { messageActions } = options + if (messageActions) { + return typeof messageActions === 'function' ? messageActions(actionContext.value) : messageActions + } + if (runtime?.message.getActions && primaryMessageId.value) { + return runtime.message.getActions(primaryMessageId.value) ?? [] + } + return [] + }) + + // --- Resolved actions --- + + const resolvedActions = computed(() => { + const actionSource = + messageActionMode.value === 'replace' ? customActions.value : [...builtInActions.value, ...customActions.value] + + const deduped = new Map() + actionSource.forEach((action) => deduped.set(action.id, action)) + + return [...deduped.values()] + .filter((action) => { + const actionRoles = action.roles + if (actionRoles?.length && (!role || !actionRoles.includes(role as never))) return false + if (action.when && action.when(actionContext.value) === false) return false + return true + }) + .sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)) + }) + + const feedbackActions = computed(() => + resolvedActions.value + .filter((action) => (action.placement ?? 'actions') === 'actions') + .map((action) => ({ name: action.id, label: action.label, icon: action.icon })), + ) + + const feedbackOperations = computed(() => + resolvedActions.value + .filter((action) => action.placement === 'operations') + .map((action) => ({ name: action.id, label: action.label })), + ) + + function getActionDefinition(actionId: string, placement?: 'actions' | 'operations') { + return resolvedActions.value.find( + (action) => action.id === actionId && (placement === undefined || (action.placement ?? 'actions') === placement), + ) + } + + // --- Usage info --- + + const usageInfo = useUsageInfo({ role, primaryMessage, isStreaming }) + + return { + shouldRender, + isEditing, + hasError, + isPendingAssistantTurn, + feedbackActions, + feedbackOperations, + getActionDefinition, + actionContext, + messageIds, + userContent, + usageInfo, + } +} + +/** + * @deprecated Use `useChatFeedback` directly — `fallbackRuntime` is now an optional param. + */ +export function useChatFeedbackWithFallbackRuntime(options: UseChatFeedbackOptions) { + return useChatFeedback(options) +} diff --git a/packages/chat/src/components/feedback/useFeedbackVisibility.ts b/packages/chat/src/components/feedback/useFeedbackVisibility.ts new file mode 100644 index 000000000..5c6a1f609 --- /dev/null +++ b/packages/chat/src/components/feedback/useFeedbackVisibility.ts @@ -0,0 +1,109 @@ +import { computed } from 'vue' +import type { ComputedRef } from 'vue' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import { getChatMessageError, isChatMessageEditing } from '@/runtime/engine/chatMessageState' +import type { ChatRuntime, ChatStatus } from '@/types' + +interface FallbackRuntime { + messages: ComputedRef + status: ComputedRef + isMessageEditing: (messageIndex: number) => boolean +} + +export interface UseFeedbackVisibilityOptions { + role?: string + enabled?: boolean + messages: ComputedRef + primaryMessage: ComputedRef + primaryMessageId: ComputedRef + messageIndexes: number[] + runtime: ChatRuntime | null + fallbackRuntime: FallbackRuntime | null + runtimeFeedbackEnabled: ComputedRef +} + +export interface UseFeedbackVisibilityReturn { + isEditing: ComputedRef + isPendingAssistantTurn: ComputedRef + hasError: ComputedRef + shouldRender: ComputedRef +} + +export function useFeedbackVisibility(options: UseFeedbackVisibilityOptions): UseFeedbackVisibilityReturn { + const { + role, + messages, + primaryMessage, + primaryMessageId, + messageIndexes, + runtime, + fallbackRuntime, + runtimeFeedbackEnabled, + } = options + + const primarySourceError = computed(() => getChatMessageError(primaryMessage.value)) + const primarySourceEditing = computed(() => isChatMessageEditing(primaryMessage.value)) + const primarySourceStreaming = computed(() => Boolean(primaryMessage.value?.loading)) + + const latestAssistantIndex = computed(() => { + if (!fallbackRuntime) return undefined + const allMessages = fallbackRuntime.messages.value + for (let index = allMessages.length - 1; index >= 0; index--) { + if (allMessages[index]?.role === 'assistant') return index + } + return undefined + }) + + const isEditing = computed(() => { + if (primaryMessageId.value && runtime) { + const runtimeEditing = runtime.message.getViewState(primaryMessageId.value)?.editing + if (runtimeEditing !== undefined) return Boolean(runtimeEditing) + } + + if (primarySourceEditing.value) return true + + if (!fallbackRuntime) return false + const primaryMessageIndex = messageIndexes[0] + if (primaryMessageIndex === undefined) return false + return Boolean(fallbackRuntime.isMessageEditing(primaryMessageIndex)) + }) + + const isPendingAssistantTurn = computed(() => { + if (role !== 'assistant') return false + + if (primaryMessageId.value && runtime) { + const status = runtime.message.getViewState(primaryMessageId.value)?.status + if (status !== undefined) return status === 'pending' || status === 'streaming' + } + + if (primarySourceStreaming.value) return true + + if (!fallbackRuntime) return false + const latestIndex = latestAssistantIndex.value + if (latestIndex === undefined) return false + return messageIndexes.includes(latestIndex) && fallbackRuntime.status.value !== 'ready' + }) + + const hasError = computed(() => { + if (primaryMessageId.value && runtime) { + const runtimeError = runtime.message.getViewState(primaryMessageId.value)?.error + if (runtimeError !== undefined) return Boolean(runtimeError) + } + + if (primarySourceError.value) return true + + return messages.value.some((message) => Boolean(getChatMessageError(message))) + }) + + const shouldRender = computed(() => { + if (!runtimeFeedbackEnabled.value) return false + if (isEditing.value) return false + if (role === 'assistant') { + if (hasError.value) return false + if (isPendingAssistantTurn.value) return false + } + return true + }) + + return { isEditing, isPendingAssistantTurn, hasError, shouldRender } +} diff --git a/packages/chat/src/components/feedback/useUsageInfo.ts b/packages/chat/src/components/feedback/useUsageInfo.ts new file mode 100644 index 000000000..6e2f04fb9 --- /dev/null +++ b/packages/chat/src/components/feedback/useUsageInfo.ts @@ -0,0 +1,46 @@ +import { computed } from 'vue' +import type { ComputedRef } from 'vue' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' + +export interface UsageInfo { + model?: string + finishReason?: string | null + createdAt?: number + promptTokens?: number + completionTokens?: number + totalTokens?: number +} + +interface UseUsageInfoOptions { + role?: string + primaryMessage: ComputedRef + isStreaming: ComputedRef +} + +export function useUsageInfo(options: UseUsageInfoOptions): ComputedRef { + return computed(() => { + if (options.role !== 'assistant') return null + + if (options.isStreaming.value) return null + + const message = options.primaryMessage.value + if (!message) return null + + const metadata = message.metadata + if (!metadata) return null + + const usage = metadata.usage as + | { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } + | undefined + if (!usage) return null + + return { + model: metadata.model as string | undefined, + finishReason: (metadata.choices as Array<{ finish_reason?: string | null }> | undefined)?.[0]?.finish_reason, + createdAt: metadata.createdAt as number | undefined, + promptTokens: usage.prompt_tokens, + completionTokens: usage.completion_tokens, + totalTokens: usage.total_tokens, + } + }) +} diff --git a/packages/chat/src/components/history/ChatHistory.vue b/packages/chat/src/components/history/ChatHistory.vue new file mode 100644 index 000000000..1a691cd38 --- /dev/null +++ b/packages/chat/src/components/history/ChatHistory.vue @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/history/ChatHistoryContent.vue b/packages/chat/src/components/history/ChatHistoryContent.vue new file mode 100644 index 000000000..496e53d8e --- /dev/null +++ b/packages/chat/src/components/history/ChatHistoryContent.vue @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/packages/chat/src/components/history/ChatHistoryList.vue b/packages/chat/src/components/history/ChatHistoryList.vue new file mode 100644 index 000000000..0b5cf87d8 --- /dev/null +++ b/packages/chat/src/components/history/ChatHistoryList.vue @@ -0,0 +1,136 @@ + + + + + + + + + + + {{ item.title }} + + + + + + diff --git a/packages/chat/src/components/history/ChatHistoryManageButton.vue b/packages/chat/src/components/history/ChatHistoryManageButton.vue new file mode 100644 index 000000000..3073a7de7 --- /dev/null +++ b/packages/chat/src/components/history/ChatHistoryManageButton.vue @@ -0,0 +1,77 @@ + + + + + + + + + + diff --git a/packages/chat/src/components/history/ChatHistoryNewSession.vue b/packages/chat/src/components/history/ChatHistoryNewSession.vue new file mode 100644 index 000000000..c7acf6996 --- /dev/null +++ b/packages/chat/src/components/history/ChatHistoryNewSession.vue @@ -0,0 +1,66 @@ + + + + + + + {{ chatMessages.history.newSession }} + + + + + diff --git a/packages/chat/src/components/history/ChatHistoryPanel.vue b/packages/chat/src/components/history/ChatHistoryPanel.vue new file mode 100644 index 000000000..0a50717d4 --- /dev/null +++ b/packages/chat/src/components/history/ChatHistoryPanel.vue @@ -0,0 +1,180 @@ + + + + + + + + {{ selectedCount }} + + + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/history/ChatHistorySearch.vue b/packages/chat/src/components/history/ChatHistorySearch.vue new file mode 100644 index 000000000..06ba7d0b7 --- /dev/null +++ b/packages/chat/src/components/history/ChatHistorySearch.vue @@ -0,0 +1,49 @@ + + + + + + + + + diff --git a/packages/chat/src/components/history/ChatHistoryToolbar.vue b/packages/chat/src/components/history/ChatHistoryToolbar.vue new file mode 100644 index 000000000..0ee27ee16 --- /dev/null +++ b/packages/chat/src/components/history/ChatHistoryToolbar.vue @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/packages/chat/src/components/history/index.ts b/packages/chat/src/components/history/index.ts new file mode 100644 index 000000000..f72537357 --- /dev/null +++ b/packages/chat/src/components/history/index.ts @@ -0,0 +1,8 @@ +export { default as ChatHistory } from './ChatHistory.vue' +export { default as ChatHistoryContent } from './ChatHistoryContent.vue' +export { default as ChatHistoryNewSession } from './ChatHistoryNewSession.vue' +export { default as ChatHistoryManageButton } from './ChatHistoryManageButton.vue' +export { default as ChatHistoryList } from './ChatHistoryList.vue' +export { default as ChatHistoryPanel } from './ChatHistoryPanel.vue' +export { default as ChatHistorySearch } from './ChatHistorySearch.vue' +export { default as ChatHistoryToolbar } from './ChatHistoryToolbar.vue' diff --git a/packages/chat/src/components/history/useHistoryState.ts b/packages/chat/src/components/history/useHistoryState.ts new file mode 100644 index 000000000..f0e402c8f --- /dev/null +++ b/packages/chat/src/components/history/useHistoryState.ts @@ -0,0 +1,33 @@ +import { ref } from 'vue' + +export function useHistoryState() { + const isManagementMode = ref(false) + const searchQuery = ref('') + const selectedItems = ref([]) + + function toggleItemSelection(id: string) { + const idx = selectedItems.value.indexOf(id) + if (idx === -1) selectedItems.value.push(id) + else selectedItems.value.splice(idx, 1) + } + + function selectAll(ids: string[]) { + selectedItems.value = [...ids] + } + + function clearSelection() { + selectedItems.value = [] + searchQuery.value = '' + } + + return { + isManagementMode, + searchQuery, + selectedItems, + toggleItemSelection, + selectAll, + clearSelection, + } +} + +export type UseHistoryStateReturn = ReturnType diff --git a/packages/chat/src/components/index.ts b/packages/chat/src/components/index.ts new file mode 100644 index 000000000..e7a527d7e --- /dev/null +++ b/packages/chat/src/components/index.ts @@ -0,0 +1,11 @@ +export { default as ChatLayout } from './ChatLayout.vue' +export { default as ChatHeader } from './ChatHeader.vue' +export { default as ChatWelcome } from './ChatWelcome.vue' +export { default as ChatMessageList } from './ChatMessageList.vue' +export { default as ChatFooter } from './ChatFooter.vue' +export { default as ChatSender } from './ChatSender.vue' +export { default as ChatProvider } from '@/entry/TrChatProvider.vue' +export { useDefaultBubbleConfig } from './useDefaultBubbleConfig' +export { useSlotFilter } from './useSlotFilter' +export type { UseDefaultBubbleConfigOptions } from './useDefaultBubbleConfig' +export * from './page-regions' diff --git a/packages/chat/src/components/mcp/ChatMcpPanel.vue b/packages/chat/src/components/mcp/ChatMcpPanel.vue new file mode 100644 index 000000000..3aa724084 --- /dev/null +++ b/packages/chat/src/components/mcp/ChatMcpPanel.vue @@ -0,0 +1,302 @@ + + + + + + + + + + + + handlePluginToggleEvent(plugin, enabled)" + @tool-toggle="(plugin, toolId, enabled) => handleToolToggleEvent(plugin, toolId, enabled)" + @plugin-create="handlePluginCreateEvent" + @plugin-delete="handlePluginDeleteEvent" + > + + + + {{ chatMessages.mcp.addPlugin }} + + + + + + + + + + + + {{ chatMessages.mcp.installPlugin }} + + + + + + + + + + + diff --git a/packages/chat/src/components/mcp/McpTrigger.vue b/packages/chat/src/components/mcp/McpTrigger.vue new file mode 100644 index 000000000..51ce69bc6 --- /dev/null +++ b/packages/chat/src/components/mcp/McpTrigger.vue @@ -0,0 +1,77 @@ + + + + + + + {{ resolvedLabel }} + + {{ activeCount }} + + + + + + diff --git a/packages/chat/src/components/mcp/index.ts b/packages/chat/src/components/mcp/index.ts new file mode 100644 index 000000000..5d463b166 --- /dev/null +++ b/packages/chat/src/components/mcp/index.ts @@ -0,0 +1,4 @@ +export { default as ChatMcpPanel } from './ChatMcpPanel.vue' +export { default as McpTrigger } from './McpTrigger.vue' +export { useMcpManager } from './useMcpManager' +export type { UseMcpManagerBridge, UseMcpManagerOptions, UseMcpManagerReturn } from './useMcpManager' diff --git a/packages/chat/src/components/mcp/useMcpManager.ts b/packages/chat/src/components/mcp/useMcpManager.ts new file mode 100644 index 000000000..e48aa9d23 --- /dev/null +++ b/packages/chat/src/components/mcp/useMcpManager.ts @@ -0,0 +1,184 @@ +import { computed, ref } from 'vue' +import type { MaybePromise, Tool, ToolCall } from '@opentiny/tiny-robot-kit' +import type { PluginInfo, PluginTool } from '@opentiny/tiny-robot' + +export interface McpToolContext { + plugin: PluginInfo + tool: PluginTool + installedPlugins: PluginInfo[] +} + +export interface McpPluginContext { + installedPlugins: PluginInfo[] +} + +export interface UseMcpManagerBridge { + getTools?: (context: McpPluginContext) => MaybePromise + callTool?: (toolCall: ToolCall, context: McpToolContext) => MaybePromise> + onPluginToggle?: (plugin: PluginInfo, enabled: boolean, context: McpPluginContext) => MaybePromise + onToolToggle?: (plugin: PluginInfo, toolId: string, enabled: boolean, context: McpPluginContext) => MaybePromise + onPluginCreate?: (type: 'form' | 'code', data: unknown, context: McpPluginContext) => MaybePromise + onPluginDelete?: (plugin: PluginInfo, context: McpPluginContext) => MaybePromise +} + +export interface UseMcpManagerOptions { + initialPlugins?: PluginInfo[] + bridge?: UseMcpManagerBridge +} + +function clonePlugins(plugins: PluginInfo[]): PluginInfo[] { + return plugins.map((plugin) => ({ + ...plugin, + tools: plugin.tools.map((tool) => ({ ...tool })), + })) +} + +function createPluginContext(installedPlugins: PluginInfo[]): McpPluginContext { + return { + installedPlugins, + } +} + +function normalizeToolResult(result: string | Record): string { + return typeof result === 'string' ? result : JSON.stringify(result) +} + +function createMissingBridgeError(toolName: string): string { + return JSON.stringify({ + error: `No MCP bridge configured for tool "${toolName}"`, + }) +} + +export function useMcpManager(options: UseMcpManagerOptions = {}) { + const installedPlugins = ref(clonePlugins(options.initialPlugins ?? [])) + const bridge = options.bridge + + const getTools = async (): Promise => { + if (bridge?.getTools) { + return await bridge.getTools(createPluginContext(installedPlugins.value)) + } + + const tools: Tool[] = [] + + for (const plugin of installedPlugins.value) { + if (!plugin.enabled) { + continue + } + + for (const tool of plugin.tools) { + if (!tool.enabled) { + continue + } + + tools.push({ + type: 'function', + function: { + name: `${plugin.id}__${tool.id}`, + description: tool.description, + parameters: { + type: 'object', + properties: {}, + required: [], + }, + }, + }) + } + } + + return tools + } + + const callTool = async (toolCall: ToolCall): Promise => { + const toolName = toolCall.function.name + const [pluginId, toolId] = toolName.split('__') + + const plugin = installedPlugins.value.find((item) => item.id === pluginId) + if (!plugin) { + return JSON.stringify({ error: `Plugin not found: ${pluginId}` }) + } + + const tool = plugin.tools.find((item) => item.id === toolId) + if (!tool) { + return JSON.stringify({ error: `Tool not found: ${toolId}` }) + } + + if (!bridge?.callTool) { + return createMissingBridgeError(toolName) + } + + return normalizeToolResult( + await bridge.callTool(toolCall, { + plugin, + tool, + installedPlugins: installedPlugins.value, + }), + ) + } + + async function handlePluginToggle(plugin: PluginInfo, enabled: boolean) { + const target = installedPlugins.value.find((item) => item.id === plugin.id) + if (target) { + target.enabled = enabled + if (!enabled) { + target.tools.forEach((tool) => { + tool.enabled = false + }) + } + } + + await bridge?.onPluginToggle?.(plugin, enabled, createPluginContext(installedPlugins.value)) + } + + async function handleToolToggle(plugin: PluginInfo, toolId: string, enabled: boolean) { + const target = installedPlugins.value.find((item) => item.id === plugin.id) + if (target) { + const tool = target.tools.find((item) => item.id === toolId) + if (tool) { + tool.enabled = enabled + } + } + + await bridge?.onToolToggle?.(plugin, toolId, enabled, createPluginContext(installedPlugins.value)) + } + + async function handlePluginCreate(type: 'form' | 'code', data: unknown) { + const plugin = await bridge?.onPluginCreate?.(type, data, createPluginContext(installedPlugins.value)) + if (!plugin) { + return + } + + const nextPlugin = clonePlugins([plugin])[0] + const existingIndex = installedPlugins.value.findIndex((item) => item.id === nextPlugin.id) + if (existingIndex >= 0) { + installedPlugins.value.splice(existingIndex, 1, nextPlugin) + } else { + installedPlugins.value.push(nextPlugin) + } + } + + async function handlePluginDelete(plugin: PluginInfo) { + const index = installedPlugins.value.findIndex((item) => item.id === plugin.id) + if (index >= 0) { + installedPlugins.value.splice(index, 1) + } + + await bridge?.onPluginDelete?.(plugin, createPluginContext(installedPlugins.value)) + } + + const activeCount = computed(() => { + return installedPlugins.value.filter((plugin) => plugin.enabled).length + }) + + return { + installedPlugins, + getTools, + callTool, + handlePluginToggle, + handleToolToggle, + handlePluginCreate, + handlePluginDelete, + activeCount, + } +} + +export type UseMcpManagerReturn = ReturnType diff --git a/packages/chat/src/components/model-selector/ModelSelector.vue b/packages/chat/src/components/model-selector/ModelSelector.vue new file mode 100644 index 000000000..62e4f7ac0 --- /dev/null +++ b/packages/chat/src/components/model-selector/ModelSelector.vue @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + {{ + model.label || model.value + }} + + + + + + + + + + + + diff --git a/packages/chat/src/components/model-selector/index.ts b/packages/chat/src/components/model-selector/index.ts new file mode 100644 index 000000000..89e11d7b7 --- /dev/null +++ b/packages/chat/src/components/model-selector/index.ts @@ -0,0 +1,4 @@ +export { default as ModelSelector } from './ModelSelector.vue' +export { useModelSelector } from './useModelSelector' +export { useFloatingDropdown } from './useFloatingDropdown' +export { useKeyboardNavigation } from './useKeyboardNavigation' diff --git a/packages/chat/src/components/model-selector/useFloatingDropdown.ts b/packages/chat/src/components/model-selector/useFloatingDropdown.ts new file mode 100644 index 000000000..63fb3b4ad --- /dev/null +++ b/packages/chat/src/components/model-selector/useFloatingDropdown.ts @@ -0,0 +1,91 @@ +import { ref, nextTick, onScopeDispose, watch } from 'vue' +import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom' +import { onClickOutside, useEventListener } from '@vueuse/core' + +export function useFloatingDropdown( + referenceEl: ReturnType>, + floatingEl: ReturnType>, +) { + const isOpen = ref(false) + let cleanupAutoUpdate: (() => void) | null = null + + const updatePosition = async () => { + if (!referenceEl.value || !floatingEl.value) return + + const { x, y } = await computePosition(referenceEl.value, floatingEl.value, { + placement: 'bottom-start', + strategy: 'absolute', + middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })], + }) + + const dpr = window.devicePixelRatio || 1 + const roundedX = Math.round(x * dpr) / dpr + const roundedY = Math.round(y * dpr) / dpr + + Object.assign(floatingEl.value.style, { + left: '0', + top: '0', + transform: `translate(${roundedX}px, ${roundedY}px)`, + }) + } + + const startAutoUpdate = () => { + if (!referenceEl.value || !floatingEl.value) return + cleanupAutoUpdate = autoUpdate(referenceEl.value, floatingEl.value, updatePosition) + } + + const stopAutoUpdate = () => { + cleanupAutoUpdate?.() + cleanupAutoUpdate = null + } + + onClickOutside( + floatingEl, + () => { + if (!isOpen.value) { + return + } + + isOpen.value = false + }, + { + ignore: [referenceEl], + }, + ) + + if (typeof document !== 'undefined') { + useEventListener(document, 'keydown', (event: KeyboardEvent) => { + if (!isOpen.value) { + return + } + + if (event.key === 'Escape') { + event.preventDefault() + isOpen.value = false + referenceEl.value?.focus() + } + }) + } + + watch(isOpen, async (newVal) => { + if (newVal) { + await nextTick() + updatePosition() + startAutoUpdate() + } else { + stopAutoUpdate() + } + }) + + watch([referenceEl, floatingEl], () => { + if (!isOpen.value) { + stopAutoUpdate() + } + }) + + onScopeDispose(() => { + stopAutoUpdate() + }) + + return { isOpen, updatePosition } +} diff --git a/packages/chat/src/components/model-selector/useKeyboardNavigation.ts b/packages/chat/src/components/model-selector/useKeyboardNavigation.ts new file mode 100644 index 000000000..6cd03f4ef --- /dev/null +++ b/packages/chat/src/components/model-selector/useKeyboardNavigation.ts @@ -0,0 +1,126 @@ +import { ref, type Ref } from 'vue' +import { useMagicKeys, onKeyStroke, whenever } from '@vueuse/core' + +export interface UseKeyboardNavigationOptions { + /** + * 是否启用键盘导航 + */ + enabled?: Ref | boolean + /** + * 项目数量 + */ + itemCount: Ref | number + /** + * 是否禁用某个项目 + */ + isItemDisabled?: (index: number) => boolean + /** + * 选择某个项目后的回调 + */ + onSelect?: (index: number) => void + /** + * 关闭列表时的回调 + */ + onClose?: () => void + /** + * 是否可以循环导航 + */ + loop?: boolean +} + +export interface UseKeyboardNavigationReturn { + /** + * 当前高亮索引 + */ + highlightedIndex: Ref + /** + * 重置高亮 + */ + reset: () => void + /** + * 设置高亮索引 + */ + setHighlightedIndex: (index: number) => void +} + +export function useKeyboardNavigation(options: UseKeyboardNavigationOptions): UseKeyboardNavigationReturn { + const { enabled = true, itemCount, isItemDisabled, onSelect, onClose, loop = false } = options + + const highlightedIndex = ref(0) + const { ArrowUp, ArrowDown } = useMagicKeys() + + const isEnabled = () => { + if (typeof enabled === 'boolean') return enabled + return enabled.value + } + + const getItemCount = () => { + if (typeof itemCount === 'number') return itemCount + return itemCount.value + } + + const reset = () => { + highlightedIndex.value = 0 + } + + const setHighlightedIndex = (index: number) => { + const count = getItemCount() + if (count <= 0) { + highlightedIndex.value = 0 + return + } + highlightedIndex.value = Math.max(0, Math.min(index, count - 1)) + } + + const switchHighlightedIndex = (isNext: boolean) => { + const count = getItemCount() + if (count <= 0) return + + let target = highlightedIndex.value + do { + if (isNext) { + target = loop ? (target + 1) % count : Math.min(target + 1, count - 1) + } else { + target = loop ? (target - 1 + count) % count : Math.max(target - 1, 0) + } + } while (isItemDisabled?.(target) && target !== highlightedIndex.value) + highlightedIndex.value = target + } + + whenever( + () => ArrowUp.value && isEnabled(), + () => { + switchHighlightedIndex(false) + }, + ) + + whenever( + () => ArrowDown.value && isEnabled(), + () => { + switchHighlightedIndex(true) + }, + ) + + onKeyStroke('Enter', (event) => { + if (!isEnabled()) return + + const count = getItemCount() + if (count > 0 && highlightedIndex.value < count) { + event.preventDefault() + onSelect?.(highlightedIndex.value) + } + }) + + onKeyStroke('Escape', (event) => { + if (!isEnabled()) return + + event.preventDefault() + onClose?.() + }) + + return { + highlightedIndex, + reset, + setHighlightedIndex, + } +} diff --git a/packages/chat/src/components/model-selector/useModelSelector.ts b/packages/chat/src/components/model-selector/useModelSelector.ts new file mode 100644 index 000000000..626277a10 --- /dev/null +++ b/packages/chat/src/components/model-selector/useModelSelector.ts @@ -0,0 +1,71 @@ +import { computed, toValue, watchEffect, type MaybeRefOrGetter, type Ref } from 'vue' +import type { ModelOption } from '@/types' +import { getProviderIcon } from '@/shared/utils/iconMap' + +export interface UseModelSelectorOptions { + currentModel: Ref + models: MaybeRefOrGetter + onChange?: (model: ModelOption) => void +} + +interface CommitModelOptions { + notifyChange?: boolean +} + +export function useModelSelector(options: UseModelSelectorOptions) { + const models = computed(() => toValue(options.models)) + + const currentModelOption = computed(() => { + return models.value.find((model) => model.value === options.currentModel.value) + }) + + const currentProvider = computed(() => { + return currentModelOption.value ? getProviderIcon(currentModelOption.value) : null + }) + + function canSelectModel(model: ModelOption) { + return !model.disabled + } + + function commitModel(model: ModelOption, commitOptions: CommitModelOptions = {}) { + const { notifyChange = true } = commitOptions + + if (!canSelectModel(model)) { + return false + } + + options.currentModel.value = model.value + + if (notifyChange) { + options.onChange?.(model) + } + + return true + } + + watchEffect(() => { + if (models.value.length === 0) { + return + } + + const selectedModel = currentModelOption.value + const isCurrentModelSelectable = selectedModel ? canSelectModel(selectedModel) : false + + if (!isCurrentModelSelectable) { + const fallbackModel = models.value.find((model) => canSelectModel(model)) + if (fallbackModel) { + commitModel(fallbackModel) + } + } + }) + + function selectModel(model: ModelOption, options?: CommitModelOptions) { + commitModel(model, options) + } + + return { + currentModelOption, + currentProvider, + selectModel, + } +} diff --git a/packages/chat/src/components/page-regions/ChatDefaultBodyRegion.vue b/packages/chat/src/components/page-regions/ChatDefaultBodyRegion.vue new file mode 100644 index 000000000..e96b6506f --- /dev/null +++ b/packages/chat/src/components/page-regions/ChatDefaultBodyRegion.vue @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/page-regions/ChatDefaultFooterRegion.vue b/packages/chat/src/components/page-regions/ChatDefaultFooterRegion.vue new file mode 100644 index 000000000..fd86fda48 --- /dev/null +++ b/packages/chat/src/components/page-regions/ChatDefaultFooterRegion.vue @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/page-regions/ChatDefaultHeaderRegion.vue b/packages/chat/src/components/page-regions/ChatDefaultHeaderRegion.vue new file mode 100644 index 000000000..39aaeb827 --- /dev/null +++ b/packages/chat/src/components/page-regions/ChatDefaultHeaderRegion.vue @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/packages/chat/src/components/page-regions/ChatPageContent.vue b/packages/chat/src/components/page-regions/ChatPageContent.vue new file mode 100644 index 000000000..7c775ac5e --- /dev/null +++ b/packages/chat/src/components/page-regions/ChatPageContent.vue @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + emit('change-model', model)" + > + + + + + + + + + + + diff --git a/packages/chat/src/components/page-regions/index.ts b/packages/chat/src/components/page-regions/index.ts new file mode 100644 index 000000000..9b932e165 --- /dev/null +++ b/packages/chat/src/components/page-regions/index.ts @@ -0,0 +1,4 @@ +export { default as ChatDefaultHeaderRegion } from './ChatDefaultHeaderRegion.vue' +export { default as ChatDefaultBodyRegion } from './ChatDefaultBodyRegion.vue' +export { default as ChatDefaultFooterRegion } from './ChatDefaultFooterRegion.vue' +export { default as ChatPageContent } from './ChatPageContent.vue' diff --git a/packages/chat/src/components/renderers/AttachmentsRenderer.vue b/packages/chat/src/components/renderers/AttachmentsRenderer.vue new file mode 100644 index 000000000..e62c06b81 --- /dev/null +++ b/packages/chat/src/components/renderers/AttachmentsRenderer.vue @@ -0,0 +1,43 @@ + + + + + + + + + diff --git a/packages/chat/src/components/renderers/EditInputRenderer.vue b/packages/chat/src/components/renderers/EditInputRenderer.vue new file mode 100644 index 000000000..766d6dfcd --- /dev/null +++ b/packages/chat/src/components/renderers/EditInputRenderer.vue @@ -0,0 +1,238 @@ + + + + + + + + + + + {{ chatMessages.editMessage.cancel }} + + + {{ isSaving ? chatMessages.editMessage.saving : chatMessages.editMessage.save }} + + + + + + + diff --git a/packages/chat/src/components/renderers/ErrorRenderer.vue b/packages/chat/src/components/renderers/ErrorRenderer.vue new file mode 100644 index 000000000..315c42716 --- /dev/null +++ b/packages/chat/src/components/renderers/ErrorRenderer.vue @@ -0,0 +1,99 @@ + + + + + + {{ error?.message || chatMessages.error.defaultMessage }} + + {{ chatMessages.error.retry }} + + + + + diff --git a/packages/chat/src/components/renderers/MarkStreamRenderer.vue b/packages/chat/src/components/renderers/MarkStreamRenderer.vue new file mode 100644 index 000000000..387473b90 --- /dev/null +++ b/packages/chat/src/components/renderers/MarkStreamRenderer.vue @@ -0,0 +1,154 @@ + + + + + + + diff --git a/packages/chat/src/components/renderers/ToolCallRenderer.vue b/packages/chat/src/components/renderers/ToolCallRenderer.vue new file mode 100644 index 000000000..1948f8e00 --- /dev/null +++ b/packages/chat/src/components/renderers/ToolCallRenderer.vue @@ -0,0 +1,109 @@ + + + + + + + + + + {{ textAndIcon.text }} + {{ toolCall?.function.name || chatMessages.toolCall.untitled }} + + + + + + + + diff --git a/packages/chat/src/components/renderers/ToolCallsRenderer.vue b/packages/chat/src/components/renderers/ToolCallsRenderer.vue new file mode 100644 index 000000000..1cd963fa2 --- /dev/null +++ b/packages/chat/src/components/renderers/ToolCallsRenderer.vue @@ -0,0 +1,21 @@ + + + + + + diff --git a/packages/chat/src/components/renderers/index.ts b/packages/chat/src/components/renderers/index.ts new file mode 100644 index 000000000..0019ad61a --- /dev/null +++ b/packages/chat/src/components/renderers/index.ts @@ -0,0 +1,6 @@ +export { default as AttachmentsRenderer } from './AttachmentsRenderer.vue' +export { default as ErrorRenderer } from './ErrorRenderer.vue' +export { default as ToolCallsRenderer } from './ToolCallsRenderer.vue' +export { default as MarkStreamRenderer } from './MarkStreamRenderer.vue' +export { default as EditInputRenderer } from './EditInputRenderer.vue' +export { default as ToolCallRenderer } from './ToolCallRenderer.vue' diff --git a/packages/chat/src/components/shared/ConditionalThemeProvider.vue b/packages/chat/src/components/shared/ConditionalThemeProvider.vue new file mode 100644 index 000000000..985364ea1 --- /dev/null +++ b/packages/chat/src/components/shared/ConditionalThemeProvider.vue @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/packages/chat/src/components/useDefaultBubbleConfig.ts b/packages/chat/src/components/useDefaultBubbleConfig.ts new file mode 100644 index 000000000..aa7c5b61c --- /dev/null +++ b/packages/chat/src/components/useDefaultBubbleConfig.ts @@ -0,0 +1,86 @@ +import { markRaw, h } from 'vue' +import { BubbleRenderers, BubbleRendererMatchPriority } from '@opentiny/tiny-robot' +import type { BubbleBoxRendererMatch, BubbleContentRendererMatch, BubbleRoleConfig } from '@opentiny/tiny-robot' +import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs' +import { + ErrorRenderer, + EditInputRenderer, + ToolCallsRenderer, + AttachmentsRenderer, + MarkStreamRenderer, +} from '@/components/renderers' +import { hasChatMessageError, isChatMessageEditing, isChatMessageOptimistic } from '@/runtime/engine/chatMessageState' + +export interface UseDefaultBubbleConfigOptions { + extraContentMatches?: BubbleContentRendererMatch[] + extraBoxMatches?: BubbleBoxRendererMatch[] + overrideRoles?: Record +} + +export function useDefaultBubbleConfig(options?: UseDefaultBubbleConfigOptions) { + const contentMatches: BubbleContentRendererMatch[] = [ + { + find: (message) => hasChatMessageError(message), + renderer: markRaw(ErrorRenderer), + priority: BubbleRendererMatchPriority.NORMAL, + }, + { + find: (message) => isChatMessageEditing(message), + renderer: markRaw(EditInputRenderer), + priority: BubbleRendererMatchPriority.NORMAL, + }, + { + find: (message) => Array.isArray(message.tool_calls) && message.tool_calls.length > 0, + renderer: markRaw(ToolCallsRenderer), + priority: BubbleRendererMatchPriority.NORMAL, + }, + { + find: (_, content) => content?.type === 'attachment', + renderer: markRaw(AttachmentsRenderer), + priority: BubbleRendererMatchPriority.CONTENT, + }, + ...(options?.extraContentMatches ?? []), + ] + + const boxMatches: BubbleBoxRendererMatch[] = [ + { + find: (messages) => messages.length === 1 && isChatMessageEditing(messages[0]), + renderer: BubbleRenderers.Box, + priority: BubbleRendererMatchPriority.NORMAL, + attributes: { 'data-editing': 'true', 'data-shape': 'none' }, + }, + { + find: (messages) => messages.length === 1 && isChatMessageOptimistic(messages[0]), + renderer: BubbleRenderers.Box, + priority: BubbleRendererMatchPriority.NORMAL, + attributes: { 'data-optimistic': 'true' }, + }, + { + find: (_, content) => content?.type === 'attachment', + renderer: BubbleRenderers.Box, + attributes: { + 'data-box-type': 'none', + 'data-shape': 'none', + }, + }, + ...(options?.extraBoxMatches ?? []), + ] + + const roles: Record = { + assistant: { + placement: 'start', + avatar: markRaw(h(IconAi, { style: { fontSize: '32px' } })), + fallbackContentRenderer: MarkStreamRenderer, + }, + user: { + placement: 'end', + avatar: markRaw(h(IconUser, { style: { fontSize: '32px' } })), + }, + system: { + hidden: true, + }, + ...options?.overrideRoles, + } + + return { contentMatches, boxMatches, roles } +} diff --git a/packages/chat/src/components/useSlotFilter.ts b/packages/chat/src/components/useSlotFilter.ts new file mode 100644 index 000000000..078063e43 --- /dev/null +++ b/packages/chat/src/components/useSlotFilter.ts @@ -0,0 +1,15 @@ +import { computed } from 'vue' +import type { ComputedRef, Slot } from 'vue' + +export function useSlotFilter( + slots: Record, + allowedNames: readonly string[], +): ComputedRef>> { + return computed(() => + Object.fromEntries( + Object.entries(slots) + .filter(([name, slot]) => allowedNames.includes(name) && slot !== undefined) + .map(([name, slot]) => [name, slot as Slot]), + ), + ) +} diff --git a/packages/chat/src/components/workspace/ChatWorkspaceLayout.vue b/packages/chat/src/components/workspace/ChatWorkspaceLayout.vue new file mode 100644 index 000000000..aba9fd460 --- /dev/null +++ b/packages/chat/src/components/workspace/ChatWorkspaceLayout.vue @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/workspace/ChatWorkspaceLeftSheet.vue b/packages/chat/src/components/workspace/ChatWorkspaceLeftSheet.vue new file mode 100644 index 000000000..6eee6c82a --- /dev/null +++ b/packages/chat/src/components/workspace/ChatWorkspaceLeftSheet.vue @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/workspace/ChatWorkspaceRightEmpty.vue b/packages/chat/src/components/workspace/ChatWorkspaceRightEmpty.vue new file mode 100644 index 000000000..0675ca627 --- /dev/null +++ b/packages/chat/src/components/workspace/ChatWorkspaceRightEmpty.vue @@ -0,0 +1,63 @@ + + + + + + + + {{ chatMessages.sidebar.emptyTitle }} + {{ chatMessages.sidebar.emptyDescription }} + + + + diff --git a/packages/chat/src/components/workspace/ChatWorkspaceRightPanel.vue b/packages/chat/src/components/workspace/ChatWorkspaceRightPanel.vue new file mode 100644 index 000000000..0afc1ff6f --- /dev/null +++ b/packages/chat/src/components/workspace/ChatWorkspaceRightPanel.vue @@ -0,0 +1,121 @@ + + + + + + {{ chatMessages.workspace.rightPanelTitle }} + + + + + + + + + + + + + diff --git a/packages/chat/src/components/workspace/ChatWorkspaceRightSheet.vue b/packages/chat/src/components/workspace/ChatWorkspaceRightSheet.vue new file mode 100644 index 000000000..b5433e8b3 --- /dev/null +++ b/packages/chat/src/components/workspace/ChatWorkspaceRightSheet.vue @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/workspace/ChatWorkspaceSidebar.vue b/packages/chat/src/components/workspace/ChatWorkspaceSidebar.vue new file mode 100644 index 000000000..f4dce6da5 --- /dev/null +++ b/packages/chat/src/components/workspace/ChatWorkspaceSidebar.vue @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/packages/chat/src/components/workspace/ChatWorkspaceSidebarRail.vue b/packages/chat/src/components/workspace/ChatWorkspaceSidebarRail.vue new file mode 100644 index 000000000..e8e3e94e7 --- /dev/null +++ b/packages/chat/src/components/workspace/ChatWorkspaceSidebarRail.vue @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/workspace/ChatWorkspaceSidebarShell.vue b/packages/chat/src/components/workspace/ChatWorkspaceSidebarShell.vue new file mode 100644 index 000000000..04bba5aff --- /dev/null +++ b/packages/chat/src/components/workspace/ChatWorkspaceSidebarShell.vue @@ -0,0 +1,146 @@ + + + + + + + + + + + {{ props.title }} + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/workspace/WorkspaceShell.vue b/packages/chat/src/components/workspace/WorkspaceShell.vue new file mode 100644 index 000000000..ca7fa0fff --- /dev/null +++ b/packages/chat/src/components/workspace/WorkspaceShell.vue @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/components/workspace/index.ts b/packages/chat/src/components/workspace/index.ts new file mode 100644 index 000000000..b4cd024e9 --- /dev/null +++ b/packages/chat/src/components/workspace/index.ts @@ -0,0 +1,9 @@ +export { default as WorkspaceShell } from './WorkspaceShell.vue' +export { default as ChatWorkspaceLayout } from './ChatWorkspaceLayout.vue' +export { default as ChatWorkspaceSidebar } from './ChatWorkspaceSidebar.vue' +export { default as ChatWorkspaceSidebarShell } from './ChatWorkspaceSidebarShell.vue' +export { default as ChatWorkspaceSidebarRail } from './ChatWorkspaceSidebarRail.vue' +export { default as ChatWorkspaceLeftSheet } from './ChatWorkspaceLeftSheet.vue' +export { default as ChatWorkspaceRightEmpty } from './ChatWorkspaceRightEmpty.vue' +export { default as ChatWorkspaceRightPanel } from './ChatWorkspaceRightPanel.vue' +export { default as ChatWorkspaceRightSheet } from './ChatWorkspaceRightSheet.vue' diff --git a/packages/chat/src/components/workspace/useWorkspaceRegion.ts b/packages/chat/src/components/workspace/useWorkspaceRegion.ts new file mode 100644 index 000000000..3d84733ce --- /dev/null +++ b/packages/chat/src/components/workspace/useWorkspaceRegion.ts @@ -0,0 +1,60 @@ +import { computed, ref, watch, type ComputedRef } from 'vue' +import type { ChatWorkspaceRegionConfig } from '@/types' +import { resolveWorkspaceCollapsedState, resolveWorkspaceRegionWidth } from './workspaceUtils' + +interface UseWorkspaceRegionOptions { + side: 'left' | 'right' + region: ComputedRef + controlledCollapsed: ComputedRef + onUpdateCollapsed: (value: boolean) => void +} + +export function useWorkspaceRegion(options: UseWorkspaceRegionOptions) { + const uncontrolledCollapsed = ref(options.region.value?.defaultOpen === false) + + const collapseMode = computed( + () => options.region.value?.collapseMode ?? (options.side === 'left' ? 'rail' : 'hidden'), + ) + const isCollapsible = computed(() => options.region.value?.collapsible !== false) + const regionWidth = computed(() => resolveWorkspaceRegionWidth(options.region.value?.width, options.side)) + + watch( + () => options.region.value?.defaultOpen, + (defaultOpen) => { + if (options.controlledCollapsed.value === undefined) { + uncontrolledCollapsed.value = defaultOpen === false + } + }, + ) + + const collapsedState = computed(() => + resolveWorkspaceCollapsedState( + options.region.value, + options.controlledCollapsed.value, + uncontrolledCollapsed.value, + ), + ) + + function updateCollapsed(nextValue: boolean) { + const resolved = isCollapsible.value ? nextValue : false + + if (options.controlledCollapsed.value === undefined) { + uncontrolledCollapsed.value = resolved + } + + options.onUpdateCollapsed(resolved) + } + + function toggleRegion() { + updateCollapsed(!collapsedState.value) + } + + return { + collapseMode, + isCollapsible, + collapsedState, + regionWidth, + updateCollapsed, + toggleRegion, + } +} diff --git a/packages/chat/src/components/workspace/workspaceUtils.ts b/packages/chat/src/components/workspace/workspaceUtils.ts new file mode 100644 index 000000000..763dc9348 --- /dev/null +++ b/packages/chat/src/components/workspace/workspaceUtils.ts @@ -0,0 +1,37 @@ +import type { ChatWorkspaceRegionConfig, ChatWorkspaceRegionWidth } from '@/types' + +export function resolveWorkspaceRegionWidth(width: ChatWorkspaceRegionWidth | undefined, side: 'left' | 'right') { + if (typeof width === 'number') { + return `${width}px` + } + + if (width === 'sm') { + return '220px' + } + + if (width === 'md') { + return '248px' + } + + if (width === 'lg') { + return '286px' + } + + return side === 'left' ? '272px' : '420px' +} + +export function resolveWorkspaceCollapsedState( + region: ChatWorkspaceRegionConfig | undefined, + controlledCollapsed: boolean | undefined, + uncontrolledCollapsed: boolean, +) { + if (region?.collapsible === false) { + return false + } + + if (controlledCollapsed !== undefined) { + return controlledCollapsed + } + + return uncontrolledCollapsed +} diff --git a/packages/chat/src/entry/RootBootstrapProvider.vue b/packages/chat/src/entry/RootBootstrapProvider.vue new file mode 100644 index 000000000..ff5824db3 --- /dev/null +++ b/packages/chat/src/entry/RootBootstrapProvider.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/chat/src/entry/TrChat.vue b/packages/chat/src/entry/TrChat.vue new file mode 100644 index 000000000..cf1917d4b --- /dev/null +++ b/packages/chat/src/entry/TrChat.vue @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/packages/chat/src/entry/TrChatPage.vue b/packages/chat/src/entry/TrChatPage.vue new file mode 100644 index 000000000..a89d9673c --- /dev/null +++ b/packages/chat/src/entry/TrChatPage.vue @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/chat/src/entry/TrChatProvider.vue b/packages/chat/src/entry/TrChatProvider.vue new file mode 100644 index 000000000..9cb19189c --- /dev/null +++ b/packages/chat/src/entry/TrChatProvider.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/chat/src/entry/TrChatRoot.vue b/packages/chat/src/entry/TrChatRoot.vue new file mode 100644 index 000000000..9c790831e --- /dev/null +++ b/packages/chat/src/entry/TrChatRoot.vue @@ -0,0 +1,36 @@ + + + + + + + diff --git a/packages/chat/src/entry/createRootBootstrapState.ts b/packages/chat/src/entry/createRootBootstrapState.ts new file mode 100644 index 000000000..80b93e069 --- /dev/null +++ b/packages/chat/src/entry/createRootBootstrapState.ts @@ -0,0 +1,295 @@ +import { computed } from 'vue' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import { getChatMessageError } from '@/runtime/engine/chatMessageState' +import type { UseChatAttachmentsReturn } from '@/components/attachments/useChatAttachments' +import type { + ChatAttachmentsFeaturePreset, + ChatMessagesOverrides, + ReadonlyRef, + ChatConversationSummary, + ChatUIMessage, + ChatRuntime, + ChatWorkspaceRegionRuntime, + TrChatRootUiConfig, + ChatWorkspaceRegionConfig, + ChatWorkspaceShellConfig, +} from '@/types' +import type { UseChatKitReturn } from '@/types/core' +import type { ChatPageInputsValue } from '@/shared/context' +import { extractMessageText, getMessageEditingState } from '@/runtime/core/normalizeRuntime' + +function toLegacyChatMessage(message: ChatUIMessage): ChatMessage { + if (message.raw && typeof message.raw === 'object') { + return message.raw as ChatMessage + } + + return { + role: message.role || 'assistant', + content: extractMessageText(message), + } as ChatMessage +} + +function createAttachmentsManager(runtime: ChatRuntime): UseChatAttachmentsReturn | undefined { + if (!runtime.attachments) { + return undefined + } + + return { + items: runtime.sender.pendingAttachments, + addFiles(files) { + const prepared = runtime.attachments?.prepareFiles(files) ?? [] + runtime.sender.addPendingAttachments(prepared) + }, + setItems(items) { + runtime.sender.setPendingAttachments(items) + }, + removeItem(item) { + runtime.sender.removePendingAttachment(item) + }, + clear() { + runtime.sender.clearPendingAttachments() + }, + } +} + +function createConversationSummary(conversation: unknown): ChatConversationSummary | null { + if (!conversation || typeof conversation !== 'object') { + return null + } + + const candidate = conversation as { id?: unknown; title?: unknown } + if (typeof candidate.id !== 'string') { + return null + } + + return { + id: candidate.id, + title: typeof candidate.title === 'string' ? candidate.title : undefined, + } +} + +function createShellRegionConfig( + region: ChatWorkspaceRegionRuntime | undefined, + fallback: ChatWorkspaceRegionConfig | undefined, +): ChatWorkspaceRegionConfig | undefined { + const enabled = region?.enabled.value ?? fallback?.enabled + + if (enabled === false) { + return { + enabled: false, + } + } + + if (!region && !fallback) { + return undefined + } + + return { + enabled: enabled ?? true, + collapsible: region?.collapsible.value ?? fallback?.collapsible, + defaultOpen: region ? region.visible.value && !region.collapsed.value : fallback?.defaultOpen, + collapseMode: region?.collapseMode.value ?? fallback?.collapseMode, + width: region?.width.value ?? fallback?.width, + railLabel: region?.railLabel.value ?? fallback?.railLabel, + } +} + +function createWorkspaceShellConfig(runtime: ChatRuntime): ChatWorkspaceShellConfig | undefined { + if (!runtime.workspace) { + return undefined + } + + return { + variant: runtime.workspace.variant.value, + leftRegion: createShellRegionConfig(runtime.workspace.left, { + enabled: Boolean(runtime.history), + collapseMode: 'rail', + }), + rightRegion: createShellRegionConfig(runtime.workspace.right, undefined), + } +} + +function createFallbackChatKit(runtimeRef: ReadonlyRef): UseChatKitReturn { + const messages = computed(() => runtimeRef.value.conversation.messages.value.map(toLegacyChatMessage)) + const conversations = computed(() => { + const history = runtimeRef.value.history + if (!history) { + return [] + } + + return history.conversations.value + .map((conversation) => createConversationSummary(conversation)) + .filter((conversation): conversation is ChatConversationSummary => Boolean(conversation)) + }) + const activeConversationId = computed(() => runtimeRef.value.history?.activeConversationId.value ?? null) + const activeConversation = computed(() => { + const currentId = activeConversationId.value + if (!currentId) { + return null + } + + return conversations.value.find((conversation) => conversation.id === currentId) ?? null + }) + const lastError = computed(() => { + const lastMessage = [...runtimeRef.value.conversation.messages.value] + .reverse() + .find((message) => runtimeRef.value.message.getViewState(message.id)?.error) + + return lastMessage ? (getChatMessageError(lastMessage.raw) ?? null) : null + }) + const placeholderRuntime = { + activeEngine: computed(() => null), + requestState: computed(() => 'idle'), + processingState: computed(() => undefined), + isProcessing: computed(() => runtimeRef.value.conversation.status.value === 'streaming'), + clear: () => undefined, + saveMessages: () => undefined, + } as UseChatKitReturn['runtime'] + + async function retry() { + return runtimeRef.value.conversation.retry() + } + + async function regenerate(messageIndex?: number) { + const target = + typeof messageIndex === 'number' ? runtimeRef.value.conversation.messages.value[messageIndex] : undefined + return runtimeRef.value.conversation.regenerate(target?.id) + } + + async function abort() { + await runtimeRef.value.conversation.abort() + } + + return { + conversations, + activeConversationId, + activeConversation, + createConversation(params?: { title?: string }) { + const result = runtimeRef.value.history?.createConversation(params?.title ? { title: params.title } : undefined) + + if (typeof result === 'string') { + return result + } + + return '' + }, + switchConversation(id: string) { + return runtimeRef.value.history?.switchConversation(id) ?? false + }, + deleteConversation(id: string) { + return runtimeRef.value.history?.deleteConversation(id) ?? false + }, + updateConversationTitle(id: string, title: string) { + runtimeRef.value.history?.renameConversation?.(id, title) + }, + abortActiveRequest: abort, + messages, + status: runtimeRef.value.conversation.status, + lastError, + sendMessage(content: string) { + return runtimeRef.value.sender.send({ text: content }) + }, + startEditMessage(messageIndex: number) { + const message = runtimeRef.value.conversation.messages.value[messageIndex] + if (message) { + runtimeRef.value.message.startEdit(message.id) + } + }, + cancelEditMessage(messageIndex: number) { + const message = runtimeRef.value.conversation.messages.value[messageIndex] + if (message) { + runtimeRef.value.message.cancelEdit(message.id) + } + }, + isMessageEditing(messageIndex: number) { + const message = runtimeRef.value.conversation.messages.value[messageIndex] + return runtimeRef.value.message.getViewState(message?.id ?? '')?.editing ?? getMessageEditingState(message) + }, + editMessage(messageIndex: number, newContent: string) { + const message = runtimeRef.value.conversation.messages.value[messageIndex] + if (message) { + return runtimeRef.value.message.commitEdit(message.id, newContent) + } + }, + updateResponseProvider(_provider: unknown) { + // Root bootstrap does not manage a provider swap path yet. + }, + abort, + retry, + regenerate, + runtime: placeholderRuntime, + } as unknown as UseChatKitReturn +} + +function createPageInputs(uiRef: ReadonlyRef, runtimeRef: ReadonlyRef) { + return computed(() => ({ + header: { + title: uiRef.value?.brand?.title, + showHistory: Boolean(runtimeRef.value.history), + showClose: false, + }, + layout: { + show: true, + contentLayout: uiRef.value?.contentLayout, + bubbleRenderers: runtimeRef.value.message.config?.renderers, + }, + welcome: uiRef.value?.welcome + ? { + title: uiRef.value.welcome.title, + description: uiRef.value.welcome.description, + icon: uiRef.value.welcome.icon ?? uiRef.value.brand?.logo, + prompts: uiRef.value.welcome.prompts, + } + : undefined, + messageList: { + autoScroll: true, + variant: 'bubble', + messageActions: runtimeRef.value.message.config?.actions, + messageActionsMode: runtimeRef.value.message.config?.actionMode, + onActionClick: undefined, + groupStrategy: undefined, + showFeedback: runtimeRef.value.message.config?.feedback?.enabled ?? false, + }, + history: { + enabled: Boolean(runtimeRef.value.history), + }, + appearance: uiRef.value?.appearance, + shell: createWorkspaceShellConfig(runtimeRef.value), + modelSelector: { + enabled: Boolean(runtimeRef.value.models), + models: runtimeRef.value.models?.models.value, + defaultModel: runtimeRef.value.models?.currentModelId.value ?? undefined, + }, + updateModel(model) { + runtimeRef.value.models?.selectModel(model.value) + }, + })) +} + +export function createRootBootstrapState( + runtimeRef: ReadonlyRef, + uiRef: ReadonlyRef, +) { + const attachmentsManager = computed(() => createAttachmentsManager(runtimeRef.value)) + const attachmentsFeature = computed(() => { + if (!runtimeRef.value.attachments) { + return undefined + } + + return { + enabled: runtimeRef.value.attachments.enabled.value, + upload: runtimeRef.value.attachments.uploadConfig?.value, + list: runtimeRef.value.attachments.listConfig?.value, + } + }) + const fallbackChatKit = createFallbackChatKit(runtimeRef) + + return { + chatKit: computed(() => fallbackChatKit), + messages: computed(() => uiRef.value?.labels), + shell: computed(() => createWorkspaceShellConfig(runtimeRef.value)), + pageInputs: createPageInputs(uiRef, runtimeRef), + attachmentsManager, + attachmentsFeature, + } +} diff --git a/packages/chat/src/entry/index.ts b/packages/chat/src/entry/index.ts new file mode 100644 index 000000000..796047ddb --- /dev/null +++ b/packages/chat/src/entry/index.ts @@ -0,0 +1,6 @@ +export { default as TrChat } from './TrChat.vue' +export { default as TrChatRoot } from './TrChatRoot.vue' +export { default as TrChatPage } from './TrChatPage.vue' +export { default as TrChatProvider } from './TrChatProvider.vue' +export { default as RootBootstrapProvider } from './RootBootstrapProvider.vue' +export { createRootBootstrapState } from './createRootBootstrapState' diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts new file mode 100644 index 000000000..2fa982b1f --- /dev/null +++ b/packages/chat/src/index.ts @@ -0,0 +1,113 @@ +import './styles/index.css' + +import { + ChatLayout as TrChatLayout, + ChatHeader as TrChatHeader, + ChatWelcome as TrChatWelcome, + ChatMessageList as TrChatMessageList, + ChatFooter as TrChatFooter, + ChatSender as TrChatSender, +} from './components' +import { TrChat, TrChatRoot, TrChatPage, TrChatProvider } from './entry' +import { + ChatWorkspaceLayout as TrChatWorkspaceLayout, + WorkspaceShell as TrChatWorkspaceShell, + ChatWorkspaceRightSheet as TrChatWorkspaceRightSheet, +} from './components/workspace' +import { ChatAttachments as TrChatAttachments } from './components/attachments' +import { ChatFeedback as TrChatFeedback } from './components/feedback' +import { ChatHistory as TrChatHistory } from './components/history' +import { McpTrigger as TrMcpTrigger } from './components/mcp' +import { ModelSelector as TrModelSelector } from './components/model-selector' + +type TrChatWithSubComponents = typeof TrChat & { + Root: typeof TrChatRoot + Page: typeof TrChatPage + Provider: typeof TrChatProvider + Layout: typeof TrChatLayout + WorkspaceLayout: typeof TrChatWorkspaceLayout + Header: typeof TrChatHeader + Welcome: typeof TrChatWelcome + MessageList: typeof TrChatMessageList + Footer: typeof TrChatFooter + Attachments: typeof TrChatAttachments + Sender: typeof TrChatSender + Feedback: typeof TrChatFeedback + History: typeof TrChatHistory + ModelSelector: typeof TrModelSelector + McpTrigger: typeof TrMcpTrigger + WorkspaceShell: typeof TrChatWorkspaceShell + WorkspaceRightSheet: typeof TrChatWorkspaceRightSheet +} + +const TrChatFull = TrChat as TrChatWithSubComponents +TrChatFull.Root = TrChatRoot +TrChatFull.Page = TrChatPage +TrChatFull.Provider = TrChatProvider +TrChatFull.Layout = TrChatLayout +TrChatFull.WorkspaceLayout = TrChatWorkspaceLayout +TrChatFull.Header = TrChatHeader +TrChatFull.Welcome = TrChatWelcome +TrChatFull.MessageList = TrChatMessageList +TrChatFull.Footer = TrChatFooter +TrChatFull.Attachments = TrChatAttachments +TrChatFull.Sender = TrChatSender +TrChatFull.Feedback = TrChatFeedback +TrChatFull.History = TrChatHistory +TrChatFull.ModelSelector = TrModelSelector +TrChatFull.McpTrigger = TrMcpTrigger +TrChatFull.WorkspaceShell = TrChatWorkspaceShell +TrChatFull.WorkspaceRightSheet = TrChatWorkspaceRightSheet + +export { TrChatFull as TrChat } + +export { useMcpManager } from './components/mcp/useMcpManager' + +export { TrMcpTrigger, TrChatFeedback } + +export { createRuntimeFromConfig } from './runtime/config' + +export type { + ChatBeforeSendInput, + ChatBeforeSendHandler, + ChatRuntimeInput, + ChatSendInput, + ChatUIMessage, + ChatContentLayout, + ChatTransportAdapter, + ChatMessageActionContext, + ChatMessageActionDefinition, + ResponseProvider, + ChatMessageActionsInput, + ChatMessageActionsMode, + ChatMessageActionPayload, + ChatMessageTransformChunkContext, + ChatMessageTransformFinishContext, + ChatMessageTransforms, + ChatListVariant, + TrChatProviderRuntimeOptions, + TrChatProps, + TrChatProviderProps, + TrChatHeaderProps, + TrChatHeaderEmits, + TrChatHeaderSlots, + TrChatHistoryProps, + TrChatWelcomeProps, + TrChatWelcomeEmits, + TrChatMessageListProps, + TrChatPageEmits, + TrChatPageMessageListSlotProps, + TrChatPageProps, + TrChatPageSenderSlotProps, + TrChatPageSlots, + TrChatSenderProps, + TrChatSenderSlots, + ModelOption, + CreateRuntimeFromConfigResult, + TrChatConfig, + TrChatConfigEntryInput, + TrChatTransportConfig, + TrChatRootProps, + TrChatRootUiConfig, + TrChatWorkspaceShellProps, +} from './types' diff --git a/packages/chat/src/internal.ts b/packages/chat/src/internal.ts new file mode 100644 index 000000000..bc10fe364 --- /dev/null +++ b/packages/chat/src/internal.ts @@ -0,0 +1,9 @@ +export { + CHAT_KIT_KEY, + CHAT_RUNTIME_KEY, + CHAT_UI_KEY, + MCP_MANAGER_KEY, + CHAT_HISTORY_KEY, + BUBBLE_CONFIG_KEY, + BUBBLE_LIST_SLOTS, +} from './shared/context' diff --git a/packages/chat/src/runtime/config/createRuntimeFromConfig.ts b/packages/chat/src/runtime/config/createRuntimeFromConfig.ts new file mode 100644 index 000000000..412e5c397 --- /dev/null +++ b/packages/chat/src/runtime/config/createRuntimeFromConfig.ts @@ -0,0 +1,662 @@ +import { computed, effectScope, ref } from 'vue' +import type { Attachment } from '@opentiny/tiny-robot' +import type { ChatMessage, ConversationStorageStrategy, UseMessagePlugin } from '@opentiny/tiny-robot-kit' +import { useChatAttachments } from '@/components/attachments/useChatAttachments' +import { detectFileType } from '@/shared/attachments' +import { createChatUiContext } from '@/shared/context' +import { createOpenAICompatibleResponseProvider } from '../transport/openaiCompatibleTransport' +import { useChatKit } from '@/runtime/engine/useChatKit' +import { + getChatMessageError, + getChatMessageTurnId, + isChatMessageEditing, + isChatMessageOptimistic, +} from '@/runtime/engine/chatMessageState' +import { ensureRuntimeMessageId } from '@/runtime/core/messageIdentity' +import { extractMessageText } from '@/runtime/core/normalizeRuntime' +import { cloneMessages } from '@/runtime/engine/useChatMessages' +import type { + ChatConversationCreateInput, + ChatConversationSummary, + ChatConversationRuntime, + ChatHistoryRuntime, + ChatMessageViewState, + ChatMessageRuntime, + ChatModelRuntime, + ChatRuntimeInput, + ChatSenderRuntime, + ChatUIMessage, + ChatWorkspaceRegionRuntime, + ChatWorkspaceRuntime, + CreateRuntimeFromConfigResult, + TrChatConfig, + TrChatRequestModel, + ChatWorkspaceRegionConfig, + ChatWorkspaceShellConfig, + ModelOption, +} from '@/types' + +function createNullStorage(): ConversationStorageStrategy { + return { + loadConversations: () => [], + loadMessages: () => [], + saveConversation: () => undefined, + saveMessages: () => undefined, + deleteConversation: () => undefined, + } +} + +function createTextParts(message: ChatMessage) { + const parts: ChatUIMessage['parts'] = [] + + if (typeof message.content === 'string' && message.content) { + parts.push({ type: 'text' as const, text: message.content }) + } + + const attachments = (message as unknown as { attachments?: Attachment[] }).attachments + if (Array.isArray(attachments)) { + for (const attachment of attachments) { + parts.push({ type: 'attachment' as const, attachment }) + } + } + + if (parts.length > 0) { + return parts + } + + if (typeof message.content === 'string') { + return [{ type: 'text' as const, text: message.content }] + } + + return [{ type: 'unknown' as const, value: message.content }] +} + +function createUiMessage(message: ChatMessage): ChatUIMessage { + const createdAt = (message as unknown as { createdAt?: number }).createdAt + const turnId = getChatMessageTurnId(message) + + return { + id: ensureRuntimeMessageId(message), + role: (message.role ?? '') as ChatUIMessage['role'], + createdAt: typeof createdAt === 'number' ? createdAt : undefined, + parts: createTextParts(message), + meta: turnId ? { turnId } : undefined, + raw: message, + } +} + +function deriveConversationTitle(messages: ChatMessage[]) { + const firstTextMessage = messages.find((message) => typeof message.content === 'string' && message.content.trim()) + return typeof firstTextMessage?.content === 'string' ? firstTextMessage.content.slice(0, 20) : undefined +} + +function toConversationSummary(conversation: unknown): ChatConversationSummary | null { + if (!conversation || typeof conversation !== 'object') { + return null + } + + const candidate = conversation as { id?: unknown; title?: unknown } + if (typeof candidate.id !== 'string') { + return null + } + + return { + id: candidate.id, + title: typeof candidate.title === 'string' ? candidate.title : undefined, + } +} + +function toModelOption(model: TrChatRequestModel): ModelOption { + return { + value: model.id, + label: model.label ?? model.id, + providerId: model.providerId, + icon: model.icon, + disabled: model.disabled, + } +} + +function resolveWorkspaceShellConfig(config: TrChatConfig): ChatWorkspaceShellConfig { + const variant = config.workspace?.defaultView ?? (config.workspace?.enabled ? 'workspace' : 'stacked') + const historyEnabled = config.history?.enabled !== false + const leftConfig = config.workspace?.left + const rightConfig = config.workspace?.right + + return { + variant, + leftRegion: { + enabled: leftConfig?.enabled ?? historyEnabled, + collapsible: leftConfig?.collapsible, + defaultOpen: leftConfig?.defaultOpen ?? (variant === 'workspace' ? (config.history?.defaultOpen ?? true) : false), + collapseMode: leftConfig?.collapseMode ?? 'rail', + width: leftConfig?.width, + railLabel: leftConfig?.railLabel, + }, + rightRegion: rightConfig + ? { + enabled: rightConfig.enabled ?? true, + collapsible: rightConfig.collapsible, + defaultOpen: rightConfig.defaultOpen, + collapseMode: rightConfig.collapseMode, + width: rightConfig.width, + railLabel: rightConfig.railLabel, + } + : variant === 'workspace' + ? { + enabled: true, + defaultOpen: false, + collapseMode: 'hidden', + } + : { + enabled: false, + }, + } +} + +function findMessageById(messages: ChatMessage[], messageId?: string) { + if (!messageId) { + return undefined + } + + return messages.find((message) => ensureRuntimeMessageId(message) === messageId) +} + +function createConversationRuntimeFromChatKit(chatKit: ReturnType): ChatConversationRuntime { + const messages = computed(() => chatKit.messages.value.map(createUiMessage)) + + return { + messages, + status: chatKit.status, + send(input) { + return chatKit.sendMessage(input.text) + }, + abort() { + return chatKit.abort() + }, + retry(messageId) { + if (!messageId) { + return chatKit.retry() + } + + const target = findMessageById(chatKit.messages.value, messageId) + if (!target) { + return false + } + + const targetTurnId = getChatMessageTurnId(target) + if (!targetTurnId) { + return false + } + + const isRetryableTurn = chatKit.messages.value.some( + (message) => getChatMessageTurnId(message) === targetTurnId && getChatMessageError(message)?.retryable, + ) + + if (!isRetryableTurn) { + return false + } + + return chatKit.retry() + }, + regenerate(messageId) { + const currentMessages = chatKit.messages.value + if (!messageId) { + return chatKit.regenerate() + } + + const target = findMessageById(currentMessages, messageId) + const targetIndex = target ? currentMessages.indexOf(target) : -1 + return targetIndex >= 0 ? chatKit.regenerate(targetIndex) : false + }, + } +} + +function createHistoryRuntimeFromChatKit(chatKit: ReturnType): ChatHistoryRuntime { + const conversations = computed(() => + chatKit.conversations.value + .map((conversation) => toConversationSummary(conversation)) + .filter((conversation): conversation is ChatConversationSummary => Boolean(conversation)), + ) + + return { + conversations, + activeConversationId: chatKit.activeConversationId, + createConversation(params?: ChatConversationCreateInput) { + const createdConversation = chatKit.createConversation({ + title: params?.title, + }) + + return createdConversation?.id ?? '' + }, + async switchConversation(id) { + const switchedConversation = await chatKit.switchConversation(id) + return Boolean(switchedConversation) + }, + async deleteConversation(id) { + await chatKit.deleteConversation(id) + return true + }, + renameConversation(id, title) { + chatKit.updateConversationTitle(id, title) + return true + }, + } +} + +function createResponseProviderForModel(config: TrChatConfig, model: TrChatRequestModel) { + const transport = config.request.providers[model.providerId] + if (!transport) { + throw new Error( + `[createRuntimeFromConfig] No provider configured for providerId "${model.providerId}". ` + + `Available providers: ${Object.keys(config.request.providers).join(', ')}`, + ) + } + return createOpenAICompatibleResponseProvider({ + providerId: model.providerId, + model: model.id, + endpoint: transport.endpoint, + baseURL: transport.baseURL, + apiPath: transport.apiPath, + systemPrompt: transport.systemPrompt ?? config.request.systemPrompt, + temperature: transport.temperature, + maxTokens: transport.maxTokens, + headers: transport.headers, + credentials: transport.credentials, + }) +} + +function createModelsRuntimeFromConfig( + config: TrChatConfig, + options?: { + onSelectModel?: (model: TrChatRequestModel) => void + }, +): ChatModelRuntime { + const models = computed(() => config.request.models.map(toModelOption)) + const currentModelId = ref(config.request.defaultModelId ?? config.request.models[0]?.id ?? null) + + function selectModel(modelId: string) { + const target = models.value.find((model) => model.value === modelId && !model.disabled) + if (!target) { + return false + } + + currentModelId.value = target.value + const resolvedModel = config.request.models.find((model) => model.id === target.value) + if (resolvedModel) { + options?.onSelectModel?.(resolvedModel) + } + return true + } + + return { + models, + currentModelId, + selectModel, + } +} + +function createWorkspaceRegionRuntime( + regionState: ReturnType['workspace']['left'], + regionConfig: ChatWorkspaceRegionConfig | undefined, +): ChatWorkspaceRegionRuntime { + return { + enabled: computed(() => regionConfig?.enabled !== false), + visible: regionState.visible, + collapsed: regionState.collapsed, + width: regionState.width, + collapseMode: regionState.collapseMode, + collapsible: computed(() => regionConfig?.collapsible !== false), + railLabel: computed(() => regionConfig?.railLabel), + open: regionState.open, + close: regionState.close, + toggle: regionState.toggle, + collapse: regionState.collapse, + expand: regionState.expand, + setWidth: regionState.setWidth, + } +} + +function createWorkspaceRuntimeFromConfig(config: TrChatConfig): ChatWorkspaceRuntime { + const shell = computed(() => resolveWorkspaceShellConfig(config)) + const chatUi = createChatUiContext({ + historyDisplay: 'drawer', + historyVisible: config.history?.defaultOpen, + shell, + }) + + return { + enabled: chatUi.workspace.enabled, + variant: chatUi.workspace.variant, + isMobile: chatUi.workspace.isMobile, + left: createWorkspaceRegionRuntime(chatUi.workspace.left, shell.value.leftRegion), + right: createWorkspaceRegionRuntime(chatUi.workspace.right, shell.value.rightRegion), + historyVisible: chatUi.history.visible, + openHistory: chatUi.history.open, + closeHistory: chatUi.history.close, + toggleHistory: chatUi.history.toggle, + setResponsiveHost: chatUi.workspace.setResponsiveHost, + } +} + +interface ResolvedRuntimeMessage { + raw: ChatMessage + ui: ChatUIMessage + index: number +} + +function resolveRuntimeMessage(messages: ChatMessage[], messageId: string): ResolvedRuntimeMessage | undefined { + for (let index = 0; index < messages.length; index += 1) { + const raw = messages[index] + if (ensureRuntimeMessageId(raw) !== messageId) { + continue + } + + return { + raw, + ui: createUiMessage(raw), + index, + } + } + + return undefined +} + +function resolveMessageViewState(message: ChatMessage, config: Pick): ChatMessageViewState { + const error = getChatMessageError(message) + const optimistic = isChatMessageOptimistic(message) + const editing = isChatMessageEditing(message) + const streaming = Boolean(message.loading) + const status = error ? 'error' : streaming ? 'streaming' : optimistic ? 'pending' : 'done' + + return { + status, + error, + editing, + optimistic, + capabilities: { + editable: message.role === 'user' && !streaming, + retryable: message.role === 'assistant' && Boolean(error?.retryable), + regeneratable: message.role === 'assistant' && !streaming && !error, + feedbackable: Boolean(config.messages?.feedback?.enabled) && message.role === 'assistant' && !streaming, + }, + } +} + +export function createMessageRuntimeFromChatKit( + chatKit: ReturnType, + config: Pick, +): ChatMessageRuntime { + function resolveMessage(messageId: string) { + return resolveRuntimeMessage(chatKit.messages.value, messageId) + } + + return { + getViewState(messageId) { + const resolved = resolveMessage(messageId) + if (!resolved) { + return undefined + } + + return resolveMessageViewState(resolved.raw, config) + }, + getActions() { + return config.messages?.actions ?? [] + }, + startEdit(messageId) { + const resolved = resolveMessage(messageId) + if (resolved) { + chatKit.startEditMessage(resolved.index) + } + }, + cancelEdit(messageId) { + const resolved = resolveMessage(messageId) + if (resolved) { + chatKit.cancelEditMessage(resolved.index) + } + }, + commitEdit(messageId, nextContent) { + const resolved = resolveMessage(messageId) + if (!resolved) { + return false + } + + chatKit.editMessage(resolved.index, nextContent) + return true + }, + async copy(messageId) { + const resolved = resolveMessage(messageId) + if (!resolved) { + return + } + + const text = extractMessageText(resolved.ui) + if (!text) { + return + } + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + } + }, + config: { + actions: config.messages?.actions, + actionMode: config.messages?.actionMode, + renderers: config.messages?.renderers, + feedback: config.messages?.feedback, + transforms: config.messages?.transforms, + }, + } +} + +function normalizeAttachment(file: File): Attachment { + return { + rawFile: file, + url: URL.createObjectURL(file), + name: file.name, + size: file.size, + fileType: detectFileType(file), + status: 'success', + } +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)) + reader.readAsDataURL(file) + }) +} + +async function resolveAttachmentUrls(attachments: Attachment[]): Promise { + return Promise.all( + attachments.map(async (attachment) => { + const url = attachment.url + // Already a usable URL (data: or https:) + if (typeof url === 'string' && !url.startsWith('blob:')) { + return attachment + } + + // Convert rawFile to data URL, then drop rawFile so it survives JSON serialization + if (attachment.rawFile) { + const dataUrl = await fileToDataUrl(attachment.rawFile) + + const { rawFile: _, ...rest } = attachment as unknown as Record + return { ...rest, url: dataUrl } as Attachment + } + + return attachment + }), + ) +} + +function createSenderRuntimeFromChatKit( + chatKit: ReturnType, + config: TrChatConfig, + attachmentsManager: ReturnType, + resolveModelId: () => string | null, +): ChatSenderRuntime { + const draft = ref('') + const canSend = computed(() => { + const status = chatKit.status.value + return Boolean(draft.value.trim()) && status !== 'submitted' && status !== 'streaming' + }) + + return { + draft, + pendingAttachments: attachmentsManager.items, + canSend, + setDraft(value) { + draft.value = value + }, + async send(input = {}) { + const payload = { + text: input.text ?? draft.value, + attachments: input.attachments ?? attachmentsManager.items.value, + modelId: input.modelId ?? resolveModelId(), + } + + if (config.lifecycle?.beforeSend) { + const result = await config.lifecycle.beforeSend({ text: payload.text, attachments: payload.attachments }) + if (result === false) { + return + } + + payload.text = result?.text ?? payload.text + if (result && 'attachments' in result && result.attachments) { + payload.attachments = result.attachments + } + } + + if (!payload.text.trim()) { + return + } + + if (payload.attachments.length > 0) { + const resolved = await resolveAttachmentUrls(payload.attachments) + chatKit.sendMessage(payload.text, { attachments: resolved }) + } else { + chatKit.sendMessage(payload.text) + } + + draft.value = '' + attachmentsManager.clear() + }, + addPendingAttachments(attachments) { + attachmentsManager.setItems([...attachmentsManager.items.value, ...attachments]) + }, + setPendingAttachments(attachments) { + attachmentsManager.setItems(attachments) + }, + removePendingAttachment(target) { + attachmentsManager.removeItem(target) + }, + clearPendingAttachments() { + attachmentsManager.clear() + }, + defaults: { + placeholder: config.sender?.placeholder, + mode: config.sender?.mode, + maxLength: config.sender?.maxLength, + wordCount: config.sender?.wordCount, + voice: config.sender?.voice, + }, + } +} + +export function createRuntimeFromConfig( + config: TrChatConfig, + options: { plugins?: UseMessagePlugin[] } = {}, +): CreateRuntimeFromConfigResult { + const scope = effectScope(true) + const result = scope.run(() => { + const defaultModelId = config.request.defaultModelId ?? config.request.models[0]?.id + const model = config.request.models.find((item) => item.id === defaultModelId) ?? config.request.models[0] + if (!model) { + throw new Error('[createRuntimeFromConfig] request.models must declare at least one model') + } + + const responseProvider = createResponseProviderForModel(config, model) + + const chatKit = useChatKit({ + responseProvider, + plugins: options.plugins, + storage: config.conversation?.persistence ?? createNullStorage(), + initialMessages: config.conversation?.initialMessages, + messageTransforms: config.messages?.transforms, + onAfterReceive: (message) => { + config.lifecycle?.afterReceive?.(message) + }, + onError: (error) => { + config.lifecycle?.error?.(error) + }, + }) + const models = createModelsRuntimeFromConfig(config, { + onSelectModel(selectedModel) { + chatKit.updateResponseProvider(createResponseProviderForModel(config, selectedModel)) + }, + }) + if (config.conversation?.initialMessages?.length && !chatKit.activeConversationId.value) { + const createdConversation = chatKit.createConversation({ + title: deriveConversationTitle(config.conversation.initialMessages), + }) + + if (createdConversation.engine.messages.value.length === 0) { + createdConversation.engine.messages.value.splice( + 0, + createdConversation.engine.messages.value.length, + ...cloneMessages(config.conversation.initialMessages), + ) + chatKit.runtime.saveMessages() + } + } + const attachmentsManager = useChatAttachments() + const conversation = createConversationRuntimeFromChatKit(chatKit) + const sender = createSenderRuntimeFromChatKit( + chatKit, + config, + attachmentsManager, + () => models.currentModelId.value ?? null, + ) + const message = createMessageRuntimeFromChatKit(chatKit, config) + const history = createHistoryRuntimeFromChatKit(chatKit) + const workspace = createWorkspaceRuntimeFromConfig(config) + + const runtime: ChatRuntimeInput = { + conversation, + sender, + message, + history, + models, + workspace, + attachments: { + enabled: computed(() => config.attachments?.enabled !== false), + prepareFiles(files) { + return files.map(normalizeAttachment) + }, + uploadConfig: computed(() => config.attachments?.upload), + listConfig: computed(() => config.attachments?.list), + }, + } + + return { + runtime, + ui: { + brand: config.ui?.brand, + welcome: config.ui?.welcome, + appearance: config.ui?.appearance, + contentLayout: config.ui?.contentLayout, + labels: config.ui?.labels, + }, + } + }) + + if (!result) { + throw new Error('[createRuntimeFromConfig] Failed to initialize runtime scope') + } + + return { + ...result, + dispose: () => scope.stop(), + } +} diff --git a/packages/chat/src/runtime/config/index.ts b/packages/chat/src/runtime/config/index.ts new file mode 100644 index 000000000..61cf94dcb --- /dev/null +++ b/packages/chat/src/runtime/config/index.ts @@ -0,0 +1,2 @@ +export { createRuntimeFromConfig } from './createRuntimeFromConfig' +export { getProviderRuntimeResolution, resolveProviderRuntime } from './resolveProviderRuntime' diff --git a/packages/chat/src/runtime/config/resolveProviderRuntime.ts b/packages/chat/src/runtime/config/resolveProviderRuntime.ts new file mode 100644 index 000000000..ee4673f34 --- /dev/null +++ b/packages/chat/src/runtime/config/resolveProviderRuntime.ts @@ -0,0 +1,50 @@ +import { useChatKit } from '@/runtime/engine/useChatKit' +import type { TrChatProviderProps, ResponseProvider, TrChatProviderRuntimeOptions } from '@/types' +import type { UseChatKitReturn } from '@/types/core' + +interface ProviderRuntimeResolution { + providerRuntimeOptions: Omit & { + responseProvider: ResponseProvider + } +} + +type ResolvedProviderRuntimeOptions = ProviderRuntimeResolution['providerRuntimeOptions'] + +export function getProviderRuntimeResolution( + componentName: string, + props: TrChatProviderProps, +): ProviderRuntimeResolution { + const hasTransportAdapter = 'transportAdapter' in props && Boolean(props.transportAdapter) + const hasResponseProvider = 'responseProvider' in props && Boolean(props.responseProvider) + + if (hasTransportAdapter && hasResponseProvider) { + throw new Error(`[${componentName}] transportAdapter and responseProvider cannot be provided together`) + } + + const responseProvider = props.transportAdapter ?? props.responseProvider + + if (!responseProvider) { + throw new Error(`[${componentName}] transportAdapter or responseProvider must be provided`) + } + + return { + providerRuntimeOptions: { + responseProvider, + plugins: props.plugins, + storage: props.storage, + initialMessages: props.initialMessages, + messageTransforms: props.messageTransforms, + onFinish: props.onFinish, + onError: props.onError, + }, + } +} + +export function resolveProviderRuntime( + componentName: string, + props: TrChatProviderProps, + createChatKit: (options: ResolvedProviderRuntimeOptions) => UseChatKitReturn = useChatKit, +): UseChatKitReturn { + const resolution = getProviderRuntimeResolution(componentName, props) + return createChatKit(resolution.providerRuntimeOptions) +} diff --git a/packages/chat/src/runtime/config/trchatConfigEntry.ts b/packages/chat/src/runtime/config/trchatConfigEntry.ts new file mode 100644 index 000000000..83d9e08d1 --- /dev/null +++ b/packages/chat/src/runtime/config/trchatConfigEntry.ts @@ -0,0 +1,68 @@ +import type { TrChatConfig } from '@/types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseSerializedConfig(value: unknown): unknown { + if (typeof value !== 'string') { + return value + } + + try { + return JSON.parse(value) + } catch { + return value + } +} + +function stableSerialize(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]` + } + + if (isRecord(value)) { + const entries = Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`) + + return `{${entries.join(',')}}` + } + + return JSON.stringify(value) +} + +export function isTargetTrChatConfig(value: unknown): value is TrChatConfig { + const parsedValue = parseSerializedConfig(value) + + if (!isRecord(parsedValue) || !isRecord(parsedValue.request)) { + return false + } + + return Array.isArray(parsedValue.request.models) && isRecord(parsedValue.request.providers) +} + +function resolveParsedTargetTrChatConfig(config: unknown): TrChatConfig | null { + const resolvedConfig = parseSerializedConfig(config) + if (!isTargetTrChatConfig(resolvedConfig)) { + return null + } + + return resolvedConfig +} + +export function resolveTrChatConfigEntry(config: unknown): TrChatConfig | null { + return resolveParsedTargetTrChatConfig(config) +} + +export function resolveTrChatConfigEntryInput(config: unknown): { config: TrChatConfig; key: string } | null { + const resolvedConfig = resolveParsedTargetTrChatConfig(config) + if (!resolvedConfig) { + return null + } + + return { + config: resolvedConfig, + key: stableSerialize(resolvedConfig), + } +} diff --git a/packages/chat/src/runtime/config/useTrChatConfigRuntimeResolution.ts b/packages/chat/src/runtime/config/useTrChatConfigRuntimeResolution.ts new file mode 100644 index 000000000..167194147 --- /dev/null +++ b/packages/chat/src/runtime/config/useTrChatConfigRuntimeResolution.ts @@ -0,0 +1,56 @@ +import { computed, shallowRef, watch, type WatchSource } from 'vue' +import { createRuntimeFromConfig } from './createRuntimeFromConfig' +import { resolveTrChatConfigEntryInput } from './trchatConfigEntry' +import type { CreateRuntimeFromConfigResult, TrChatConfigEntryInput } from '@/types' + +const TR_CHAT_CONFIG_ENTRY_ERROR = + '[TrChat] The TrChat config entry accepts only target TrChatConfig or serialized target TrChatConfig. Use TrChat.Root + TrChat.Page or TrChat.Root + primitives for composed integration.' + +export function useTrChatConfigRuntimeResolution( + configSource: WatchSource, + options?: { + createRuntime?: (config: Parameters[0]) => CreateRuntimeFromConfigResult + }, +) { + const createRuntime = options?.createRuntime ?? createRuntimeFromConfig + const runtimeResolutionRef = shallowRef(null) + const runtimeResolutionError = shallowRef(null) + const activeConfigKey = shallowRef(null) + + watch( + configSource, + (nextConfig) => { + const resolved = resolveTrChatConfigEntryInput(nextConfig) + if (!resolved) { + activeConfigKey.value = null + runtimeResolutionRef.value = null + runtimeResolutionError.value = new Error(TR_CHAT_CONFIG_ENTRY_ERROR) + return + } + + if (runtimeResolutionRef.value && activeConfigKey.value === resolved.key) { + runtimeResolutionError.value = null + return + } + + const previousResolution = runtimeResolutionRef.value + activeConfigKey.value = resolved.key + runtimeResolutionError.value = null + runtimeResolutionRef.value = createRuntime(resolved.config) + previousResolution?.dispose?.() + }, + { immediate: true }, + ) + + return computed(() => { + if (runtimeResolutionError.value) { + throw runtimeResolutionError.value + } + + if (!runtimeResolutionRef.value) { + throw new Error('[TrChat] Failed to initialize the TrChat config runtime resolution.') + } + + return runtimeResolutionRef.value + }) +} diff --git a/packages/chat/src/runtime/core/messageIdentity.ts b/packages/chat/src/runtime/core/messageIdentity.ts new file mode 100644 index 000000000..78719026e --- /dev/null +++ b/packages/chat/src/runtime/core/messageIdentity.ts @@ -0,0 +1,65 @@ +const CHAT_RUNTIME_MESSAGE_ID = Symbol('chatRuntimeMessageId') +const CHAT_RUNTIME_MESSAGE_ID_STATE_KEY = '__trMessageId' + +type MessageIdCarrier = { + [CHAT_RUNTIME_MESSAGE_ID]?: string +} + +type MessageStateCarrier = { + state?: Record +} + +function createRuntimeMessageId() { + return `tr-msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` +} + +function getPersistedRuntimeMessageId(target: unknown): string | undefined { + if (!target || typeof target !== 'object') { + return undefined + } + + const state = (target as MessageStateCarrier).state + const persistedId = state?.[CHAT_RUNTIME_MESSAGE_ID_STATE_KEY] + return typeof persistedId === 'string' ? persistedId : undefined +} + +function persistRuntimeMessageId(target: object, messageId: string) { + const carrier = target as MessageStateCarrier + if (!carrier.state || typeof carrier.state !== 'object') { + carrier.state = {} + } + + carrier.state[CHAT_RUNTIME_MESSAGE_ID_STATE_KEY] = messageId +} + +export function getRuntimeMessageId(target: unknown): string | undefined { + if (!target || typeof target !== 'object') { + return undefined + } + + return (target as MessageIdCarrier)[CHAT_RUNTIME_MESSAGE_ID] ?? getPersistedRuntimeMessageId(target) +} + +export function setRuntimeMessageId(target: object, messageId: string): string { + const carrier = target as MessageIdCarrier + if (carrier[CHAT_RUNTIME_MESSAGE_ID] !== messageId) { + Object.defineProperty(carrier, CHAT_RUNTIME_MESSAGE_ID, { + value: messageId, + writable: true, + configurable: true, + enumerable: false, + }) + } + + persistRuntimeMessageId(target, messageId) + return messageId +} + +export function ensureRuntimeMessageId(target: object, preferredId?: string): string { + const existingMessageId = getRuntimeMessageId(target) + if (existingMessageId) { + return setRuntimeMessageId(target, existingMessageId) + } + + return setRuntimeMessageId(target, preferredId ?? createRuntimeMessageId()) +} diff --git a/packages/chat/src/runtime/core/normalizeRuntime.ts b/packages/chat/src/runtime/core/normalizeRuntime.ts new file mode 100644 index 000000000..2d3822bc2 --- /dev/null +++ b/packages/chat/src/runtime/core/normalizeRuntime.ts @@ -0,0 +1,150 @@ +import { computed, ref } from 'vue' +import type { Attachment } from '@opentiny/tiny-robot' +import { getChatMessageError, isChatMessageEditing, isChatMessageOptimistic } from '@/runtime/engine/chatMessageState' +import type { + ChatMessageRuntime, + ChatRuntime, + ChatRuntimeInput, + ChatSendInput, + ChatSenderRuntime, + ChatUIMessage, +} from '@/types' + +function extractText(message: ChatUIMessage): string { + return message.parts + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text) + .join('\n') +} + +function createDefaultSenderRuntime(runtimeInput: ChatRuntimeInput): ChatSenderRuntime { + const draft = ref('') + const pendingAttachments = ref([]) + const canSend = computed(() => { + const hasText = Boolean(draft.value.trim()) + const status = runtimeInput.conversation.status.value + return hasText && status !== 'submitted' && status !== 'streaming' + }) + + async function send(input: Partial = {}) { + const text = input.text ?? draft.value + if (!text.trim()) { + return + } + + await runtimeInput.conversation.send({ + text, + attachments: input.attachments ?? pendingAttachments.value, + modelId: input.modelId ?? null, + }) + draft.value = '' + pendingAttachments.value = [] + } + + return { + draft, + pendingAttachments, + canSend, + setDraft(value) { + draft.value = value + }, + send, + addPendingAttachments(attachments) { + pendingAttachments.value = [...pendingAttachments.value, ...attachments] + }, + setPendingAttachments(attachments) { + pendingAttachments.value = [...attachments] + }, + removePendingAttachment(target) { + pendingAttachments.value = pendingAttachments.value.filter((item) => item !== target) + }, + clearPendingAttachments() { + pendingAttachments.value = [] + }, + } +} + +function createDefaultMessageRuntime(runtimeInput: ChatRuntimeInput): ChatMessageRuntime { + function resolveMessage(messageId: string) { + return runtimeInput.conversation.messages.value.find((message) => message.id === messageId) + } + + return { + getViewState(messageId) { + const message = resolveMessage(messageId) + if (!message) { + return undefined + } + + const raw = message.raw + const status = + raw && typeof raw === 'object' && 'loading' in raw && raw.loading + ? 'streaming' + : getChatMessageError(raw) + ? 'error' + : isChatMessageOptimistic(raw) + ? 'pending' + : 'done' + + return { + status, + error: getChatMessageError(raw), + editing: isChatMessageEditing(raw), + optimistic: isChatMessageOptimistic(raw), + capabilities: { + editable: message.role === 'user', + retryable: message.role === 'assistant', + regeneratable: message.role === 'assistant', + feedbackable: message.role === 'assistant', + }, + } + }, + startEdit() { + console.warn( + '[TrChat] message.startEdit() called on the default message runtime. Provide a full ChatMessageRuntime to enable editing.', + ) + }, + cancelEdit() { + console.warn( + '[TrChat] message.cancelEdit() called on the default message runtime. Provide a full ChatMessageRuntime to enable editing.', + ) + }, + async commitEdit() { + console.warn( + '[TrChat] message.commitEdit() called on the default message runtime. Provide a full ChatMessageRuntime to enable editing.', + ) + return false + }, + async copy(messageId) { + const message = resolveMessage(messageId) + const text = message ? extractText(message) : '' + if (!text) { + return + } + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + } + }, + } +} + +export function normalizeChatRuntime(runtimeInput: ChatRuntimeInput): ChatRuntime { + return { + ...runtimeInput, + sender: runtimeInput.sender ?? createDefaultSenderRuntime(runtimeInput), + message: runtimeInput.message ?? createDefaultMessageRuntime(runtimeInput), + } +} + +export function extractMessageText(message: ChatUIMessage | undefined) { + if (!message) { + return '' + } + + return extractText(message) +} + +export function getMessageEditingState(message: ChatUIMessage | undefined) { + return Boolean(message?.raw && isChatMessageEditing(message.raw)) +} diff --git a/packages/chat/src/runtime/engine/chatMessageState.ts b/packages/chat/src/runtime/engine/chatMessageState.ts new file mode 100644 index 000000000..5373baebe --- /dev/null +++ b/packages/chat/src/runtime/engine/chatMessageState.ts @@ -0,0 +1,75 @@ +import type { ChatErrorInfo } from '@/types' + +type MessageStateCarrier = { + state?: Record +} + +export interface ChatMessageRuntimeState extends Record { + error?: ChatErrorInfo + isEditing?: boolean + optimistic?: boolean + turnId?: string +} + +function asMessageStateCarrier(message: unknown): MessageStateCarrier | null | undefined { + return message as MessageStateCarrier | null | undefined +} + +export function getChatMessageState(message: unknown): ChatMessageRuntimeState | undefined { + return asMessageStateCarrier(message)?.state as ChatMessageRuntimeState | undefined +} + +export function ensureChatMessageState(message: unknown): ChatMessageRuntimeState { + const stateCarrier = message as MessageStateCarrier + stateCarrier.state ??= {} + return stateCarrier.state as ChatMessageRuntimeState +} + +export function getChatMessageError(message: unknown): ChatErrorInfo | undefined { + return getChatMessageState(message)?.error +} + +export function hasChatMessageError(message: unknown): boolean { + return Boolean(getChatMessageError(message)) +} + +export function setChatMessageError(message: unknown, error: ChatErrorInfo | undefined) { + if (!message) return + + const state = ensureChatMessageState(message) + state.error = error +} + +export function isChatMessageEditing(message: unknown): boolean { + return getChatMessageState(message)?.isEditing === true +} + +export function setChatMessageEditing(message: unknown, isEditing: boolean) { + if (!message) return + + const state = ensureChatMessageState(message) + state.isEditing = isEditing || undefined +} + +export function getChatMessageTurnId(message: unknown) { + const turnId = getChatMessageState(message)?.turnId + return typeof turnId === 'string' ? turnId : undefined +} + +export function setChatMessageTurnId(message: unknown, turnId: string) { + if (!message) return + + const state = ensureChatMessageState(message) + state.turnId = turnId +} + +export function setChatMessageOptimistic(message: unknown, isOptimistic: boolean) { + if (!message) return + + const state = ensureChatMessageState(message) + state.optimistic = isOptimistic || undefined +} + +export function isChatMessageOptimistic(message: unknown): boolean { + return getChatMessageState(message)?.optimistic === true +} diff --git a/packages/chat/src/runtime/engine/chatRenderMessages.ts b/packages/chat/src/runtime/engine/chatRenderMessages.ts new file mode 100644 index 000000000..1bc6e78a2 --- /dev/null +++ b/packages/chat/src/runtime/engine/chatRenderMessages.ts @@ -0,0 +1,55 @@ +import type { ChatMessage } from '@opentiny/tiny-robot-kit' + +const CHAT_RENDER_SOURCE_MESSAGE = Symbol('chat-render-source-message') +const CHAT_RENDER_MESSAGE_INDEX = Symbol('chat-render-message-index') + +type ChatRenderAnnotatedMessage = ChatMessage & { + [CHAT_RENDER_SOURCE_MESSAGE]?: ChatMessage + [CHAT_RENDER_MESSAGE_INDEX]?: number +} + +function defineHiddenProperty(target: T, key: K, value: unknown) { + Object.defineProperty(target, key, { + value, + writable: true, + configurable: true, + enumerable: false, + }) +} + +export function normalizeChatRenderMessages(messages: ChatMessage[]): ChatMessage[] { + messages.forEach((message, index) => { + const annotatedMessage = message as ChatRenderAnnotatedMessage + + if (annotatedMessage[CHAT_RENDER_SOURCE_MESSAGE] !== message) { + defineHiddenProperty(annotatedMessage, CHAT_RENDER_SOURCE_MESSAGE, message) + } + + if (annotatedMessage[CHAT_RENDER_MESSAGE_INDEX] !== index) { + defineHiddenProperty(annotatedMessage, CHAT_RENDER_MESSAGE_INDEX, index) + } + }) + + return messages +} + +export function getChatRenderSourceMessage(message: ChatMessage | null | undefined): ChatMessage | undefined { + if (!message) { + return undefined + } + + const annotatedMessage = message as ChatRenderAnnotatedMessage + return annotatedMessage[CHAT_RENDER_SOURCE_MESSAGE] ?? message +} + +export function getChatRenderMessageIndex(message: ChatMessage | null | undefined): number | undefined { + if (!message) { + return undefined + } + + return (message as ChatRenderAnnotatedMessage)[CHAT_RENDER_MESSAGE_INDEX] +} + +export function unwrapChatRenderMessages(messages: ChatMessage[]): ChatMessage[] { + return messages.map((message) => getChatRenderSourceMessage(message) ?? message) +} diff --git a/packages/chat/src/runtime/engine/index.ts b/packages/chat/src/runtime/engine/index.ts new file mode 100644 index 000000000..02fac4736 --- /dev/null +++ b/packages/chat/src/runtime/engine/index.ts @@ -0,0 +1,24 @@ +export { useChatKit } from './useChatKit' +export { useChatConversation } from './useChatConversation' +export { useChatRequest } from './useChatRequest' +export { useChatMessages } from './useChatMessages' +export { + getChatRenderMessageIndex, + getChatRenderSourceMessage, + normalizeChatRenderMessages, + unwrapChatRenderMessages, +} from './chatRenderMessages' +export { + ensureChatMessageState, + getChatMessageError, + getChatMessageState, + getChatMessageTurnId, + hasChatMessageError, + isChatMessageEditing, + isChatMessageOptimistic, + setChatMessageEditing, + setChatMessageError, + setChatMessageOptimistic, + setChatMessageTurnId, +} from './chatMessageState' +export type { ChatMessageRuntimeState } from './chatMessageState' diff --git a/packages/chat/src/runtime/engine/useChatConversation.ts b/packages/chat/src/runtime/engine/useChatConversation.ts new file mode 100644 index 000000000..f01e1f8de --- /dev/null +++ b/packages/chat/src/runtime/engine/useChatConversation.ts @@ -0,0 +1,233 @@ +import { useConversation } from '@opentiny/tiny-robot-kit' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import type { BasePluginContext, UseMessagePlugin, UseMessageOptions } from '@opentiny/tiny-robot-kit' +import type { UseConversationReturn } from '@opentiny/tiny-robot-kit' +import type { ShallowRef } from 'vue' +import type { UseChatKitOptions, UseMessageResponseProvider } from '@/types/core' + +type UseChatConversationOptions = Pick< + UseChatKitOptions, + 'plugins' | 'storage' | 'initialMessages' | 'messageTransforms' | 'onAfterReceive' | 'onFinish' | 'onError' +> & { + responseProviderRef: ShallowRef + onTurnError?: (payload: { context: BasePluginContext & { error: unknown }; error: unknown }) => void +} + +function applyMessageTransformPatch(message: ChatMessage, patch: Partial | void) { + if (!patch) { + return + } + + const { metadata, state, ...restPatch } = patch as Partial & { + state?: Record + } + + Object.assign(message, restPatch) + + if (metadata) { + message.metadata = { + ...(message.metadata ?? {}), + ...metadata, + } + } + + if (state && typeof state === 'object') { + const currentState = + typeof (message as ChatMessage & { state?: Record }).state === 'object' + ? (message as ChatMessage & { state?: Record }).state + : {} + ;(message as ChatMessage & { state?: Record }).state = { + ...currentState, + ...state, + } + } +} + +function createTransformPlugin(options: Pick): UseMessagePlugin { + return { + name: 'chatkit-transforms', + onCompletionChunk(context) { + if (!options.messageTransforms?.onChunk) { + return + } + + try { + options.messageTransforms.onChunk(context) + } catch (error) { + console.error('[useChatConversation] messageTransforms.onChunk failed:', error) + } + }, + onTurnEnd(context) { + if (!options.messageTransforms?.onFinish) { + return + } + + const lastAssistantMessage = [...context.currentTurn] + .reverse() + .find((message: ChatMessage) => message.role === 'assistant') + if (!lastAssistantMessage) { + return + } + + try { + const patch = options.messageTransforms.onFinish({ + ...context, + message: lastAssistantMessage, + }) + applyMessageTransformPatch(lastAssistantMessage, patch) + } catch (error) { + console.error('[useChatConversation] messageTransforms.onFinish failed:', error) + } + }, + } +} + +function createAfterReceivePlugin(options: Pick): UseMessagePlugin { + return { + name: 'chatkit-after-receive', + onTurnEnd(ctx: BasePluginContext) { + if (!options.onAfterReceive) return + + const lastAssistantMessage = [...ctx.currentTurn] + .reverse() + .find((message: ChatMessage) => message.role === 'assistant') + if (lastAssistantMessage) { + options.onAfterReceive(lastAssistantMessage) + } + }, + } +} + +function createLifecyclePlugin( + options: Pick & Pick, +): UseMessagePlugin { + return { + name: 'chatkit-lifecycle', + onTurnEnd(ctx: BasePluginContext) { + if (!options.onFinish) return + + const lastAssistantMessage = [...ctx.currentTurn] + .reverse() + .find((message: ChatMessage) => message.role === 'assistant') + if (lastAssistantMessage) { + options.onFinish(lastAssistantMessage) + } + }, + onError(ctx: BasePluginContext & { error: unknown }) { + options.onTurnError?.({ context: ctx, error: ctx.error }) + options.onError?.(ctx.error instanceof Error ? ctx.error : new Error(String(ctx.error))) + }, + } +} + +export function useChatConversation(options: UseChatConversationOptions): Pick< + UseConversationReturn, + | 'conversations' + | 'activeConversationId' + | 'activeConversation' + | 'switchConversation' + | 'deleteConversation' + | 'clear' + | 'saveMessages' + | 'updateConversationTitle' + | 'abortActiveRequest' +> & { + createConversation: ( + params?: Parameters[0], + ) => ReturnType + sendMessage: (content: string, options?: { attachments?: unknown[] }) => void +} { + const { + plugins = [], + storage, + initialMessages = [], + messageTransforms, + onAfterReceive, + onFinish, + onError, + onTurnError, + responseProviderRef, + } = options + + const conversation = useConversation({ + useMessageOptions: { + responseProvider: responseProviderRef.value as UseMessageOptions['responseProvider'], + plugins: [ + ...plugins, + createAfterReceivePlugin({ onAfterReceive }), + createTransformPlugin({ messageTransforms }), + createLifecyclePlugin({ onFinish, onError, onTurnError }), + ] as UseMessagePlugin[], + }, + autoSaveMessages: !!storage, + storage, + }) + + function resolveConversationMessageOptions( + useMessageOptions?: Partial, + fallbackInitialMessages: ChatMessage[] = [], + ): Partial { + return { + responseProvider: responseProviderRef.value as UseMessageOptions['responseProvider'], + ...useMessageOptions, + initialMessages: useMessageOptions?.initialMessages ?? fallbackInitialMessages, + } + } + + function createConversation(params?: Parameters[0]) { + return conversation.createConversation({ + ...params, + useMessageOptions: { + ...resolveConversationMessageOptions(params?.useMessageOptions, []), + initialMessages: [], + }, + }) + } + + function sendMessage(content: string, options?: { attachments?: unknown[] }): void { + if (!content.trim()) return + + let engine = conversation.activeConversation.value?.engine + + if (!engine) { + const createdConversation = conversation.createConversation({ + title: content.slice(0, 20), + useMessageOptions: resolveConversationMessageOptions(undefined, [...initialMessages]), + }) + + engine = createdConversation?.engine ?? conversation.activeConversation.value?.engine + } + + if (!engine) { + console.warn('[useChatConversation] sendMessage: no active engine after createConversation') + return + } + + const attachments = options?.attachments + if (attachments && attachments.length > 0) { + const now = Math.floor(Date.now() / 1000) + engine.send({ + role: 'user', + content, + attachments, + metadata: { createdAt: now, updatedAt: now }, + } as Parameters[0]) + } else { + engine.sendMessage(content) + } + } + + return { + conversations: conversation.conversations, + activeConversationId: conversation.activeConversationId, + activeConversation: conversation.activeConversation, + createConversation, + switchConversation: conversation.switchConversation, + deleteConversation: conversation.deleteConversation, + clear: conversation.clear, + saveMessages: conversation.saveMessages, + updateConversationTitle: conversation.updateConversationTitle, + abortActiveRequest: conversation.abortActiveRequest, + sendMessage, + } +} diff --git a/packages/chat/src/runtime/engine/useChatKit.ts b/packages/chat/src/runtime/engine/useChatKit.ts new file mode 100644 index 000000000..dc0cc01fc --- /dev/null +++ b/packages/chat/src/runtime/engine/useChatKit.ts @@ -0,0 +1,389 @@ +import { computed, shallowRef, watch, watchEffect } from 'vue' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import type { UseChatKitOptions, UseChatKitReturn, UseMessageResponseProvider } from '@/types/core' +import { getRuntimeMessageId, setRuntimeMessageId } from '@/runtime/core/messageIdentity' +import { useChatConversation } from '../engine/useChatConversation' +import { cloneMessages, useChatMessages } from './useChatMessages' +import { useChatRequest } from './useChatRequest' +import { + getChatMessageTurnId, + setChatMessageError, + setChatMessageOptimistic, + setChatMessageTurnId, +} from '../engine/chatMessageState' + +interface RetryContext { + conversationId: string + turnId: string + userContent: string +} + +interface OptimisticTurnContext { + conversationId: string + turnId: string + userMessage: ChatMessage | null + assistantMessage: ChatMessage | null +} + +interface EditRollbackContext { + conversationId: string + messageIndex: number + removedMessages: ChatMessage[] +} + +interface ResendMessageOptions { + preserveUserMessageId?: string + attachments?: unknown[] +} + +function createTurnId() { + return `turn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +function findLatestUserMessageWithoutTurnId(messages: ChatMessage[]): ChatMessage | null { + return [...messages].reverse().find((message) => message.role === 'user' && !getChatMessageTurnId(message)) ?? null +} + +function findUserMessageByTurnId(messages: ChatMessage[], turnId: string): ChatMessage | null { + return messages.find((message) => message.role === 'user' && getChatMessageTurnId(message) === turnId) ?? null +} + +function findAssistantMessageByTurnId(messages: ChatMessage[], turnId: string): ChatMessage | null { + return messages.find((message) => getChatMessageTurnId(message) === turnId && message.role !== 'user') ?? null +} + +function findAssistantMessageForTurn(messages: ChatMessage[], userMessage: ChatMessage | null): ChatMessage | null { + if (!userMessage) return null + + const userIndex = messages.findIndex((message) => message === userMessage) + if (userIndex === -1) return null + + return ( + messages + .slice(userIndex + 1) + .find((message) => message.loading || message.role === 'assistant' || message.role === '') ?? null + ) +} + +function findPreviousUserMessageIndex(messages: ChatMessage[], startIndex: number): number { + for (let index = startIndex; index >= 0; index -= 1) { + if (messages[index]?.role === 'user') { + return index + } + } + + return -1 +} + +export function useChatKit(options: UseChatKitOptions): UseChatKitReturn { + const responseProviderRef = shallowRef( + options.responseProvider as UseMessageResponseProvider, + ) + const retryContext = shallowRef(null) + const optimisticTurn = shallowRef(null) + const editRollbackContext = shallowRef(null) + + function clearFailureState() { + retryContext.value = null + request.clearLastError() + } + + function clearOptimisticTurn() { + if (!optimisticTurn.value) return + + setChatMessageOptimistic(optimisticTurn.value.userMessage, false) + setChatMessageOptimistic(optimisticTurn.value.assistantMessage, false) + optimisticTurn.value = null + } + + function clearPendingEditRollback() { + editRollbackContext.value = null + } + + function resetTransientState() { + clearFailureState() + clearOptimisticTurn() + clearPendingEditRollback() + } + + const conversation = useChatConversation({ + plugins: options.plugins, + storage: options.storage, + initialMessages: options.initialMessages, + messageTransforms: options.messageTransforms, + onAfterReceive: options.onAfterReceive, + onFinish: options.onFinish, + onError: options.onError, + onTurnError: ({ context, error }) => { + const normalizedError = request.captureError(error) + const currentConversationId = conversation.activeConversationId.value + const userMessage = context.currentTurn.find( + (message) => message.role === 'user' && typeof message.content === 'string' && message.content.trim(), + ) + const failedAssistantMessage = [...context.currentTurn].reverse().find((message) => message !== userMessage) + + if (failedAssistantMessage) { + failedAssistantMessage.role = failedAssistantMessage.role || 'assistant' + failedAssistantMessage.loading = undefined + setChatMessageError(failedAssistantMessage, normalizedError) + } + + if ( + editRollbackContext.value && + currentConversationId && + editRollbackContext.value.conversationId === currentConversationId + ) { + const activeMessages = conversation.activeConversation.value?.engine.messages.value + if (activeMessages) { + activeMessages.splice( + editRollbackContext.value.messageIndex, + activeMessages.length - editRollbackContext.value.messageIndex, + ...editRollbackContext.value.removedMessages, + ) + } + clearPendingEditRollback() + } + + if ( + currentConversationId && + userMessage && + typeof userMessage.content === 'string' && + normalizedError.retryable + ) { + const turnId = getChatMessageTurnId(userMessage) ?? createTurnId() + setChatMessageTurnId(userMessage, turnId) + setChatMessageTurnId(failedAssistantMessage ?? null, turnId) + + retryContext.value = { + conversationId: currentConversationId, + turnId, + userContent: userMessage.content, + } + } else { + retryContext.value = null + } + }, + responseProviderRef, + }) + + const request = useChatRequest({ + conversation, + responseProviderRef, + }) + const activeEngine = computed(() => conversation.activeConversation.value?.engine ?? null) + const runtimeRequestState = computed(() => activeEngine.value?.requestState.value ?? 'idle') + const runtimeProcessingState = computed(() => activeEngine.value?.processingState.value) + const runtimeIsProcessing = computed(() => activeEngine.value?.isProcessing.value ?? false) + + const messages = computed(() => conversation.activeConversation.value?.engine.messages.value ?? []) + + function resendMessage(content: string, options: ResendMessageOptions = {}) { + conversation.sendMessage(content, options.attachments ? { attachments: options.attachments } : undefined) + markOptimisticTurn(options) + } + + const messageActions = useChatMessages({ + messages, + resendMessage, + onOptimisticEdit: ({ messageIndex, removedMessages }) => { + const currentConversationId = conversation.activeConversationId.value + if (!currentConversationId) return + + clearFailureState() + clearPendingEditRollback() + editRollbackContext.value = { + conversationId: currentConversationId, + messageIndex, + removedMessages, + } + }, + }) + + function markOptimisticTurn(options: ResendMessageOptions = {}) { + const currentConversationId = conversation.activeConversationId.value + const activeMessages = conversation.activeConversation.value?.engine.messages.value + if (!currentConversationId || !activeMessages) return + + const turnId = createTurnId() + const userMessage = findLatestUserMessageWithoutTurnId(activeMessages) + if (!userMessage) return + + if (options.preserveUserMessageId) { + setRuntimeMessageId(userMessage, options.preserveUserMessageId) + } + + setChatMessageTurnId(userMessage, turnId) + const assistantMessage = findAssistantMessageForTurn(activeMessages, userMessage) + setChatMessageTurnId(assistantMessage, turnId) + setChatMessageOptimistic(userMessage, true) + setChatMessageOptimistic(assistantMessage, true) + + optimisticTurn.value = { + conversationId: currentConversationId, + turnId, + userMessage, + assistantMessage, + } + } + + watchEffect(() => { + if (!optimisticTurn.value) return + + const currentConversationId = conversation.activeConversationId.value + const activeMessages = conversation.activeConversation.value?.engine.messages.value ?? [] + if (!currentConversationId || currentConversationId !== optimisticTurn.value.conversationId) { + clearOptimisticTurn() + return + } + + if (!optimisticTurn.value.userMessage) { + optimisticTurn.value.userMessage = findUserMessageByTurnId(activeMessages, optimisticTurn.value.turnId) + setChatMessageOptimistic(optimisticTurn.value.userMessage, true) + } + + if (!optimisticTurn.value.assistantMessage) { + optimisticTurn.value.assistantMessage = + findAssistantMessageByTurnId(activeMessages, optimisticTurn.value.turnId) ?? + findAssistantMessageForTurn(activeMessages, optimisticTurn.value.userMessage) + setChatMessageTurnId(optimisticTurn.value.assistantMessage, optimisticTurn.value.turnId) + setChatMessageOptimistic(optimisticTurn.value.assistantMessage, true) + } + + if (request.status.value === 'ready' || request.status.value === 'error') { + clearOptimisticTurn() + } + + if (request.status.value === 'ready') { + clearPendingEditRollback() + } + }) + + watch( + () => conversation.activeConversationId.value, + (conversationId, previousConversationId) => { + if (previousConversationId != null && conversationId !== previousConversationId) { + resetTransientState() + } + }, + ) + + function sendMessage(content: string, options?: { attachments?: unknown[] }): void { + if (!content.trim()) return + clearFailureState() + resendMessage(content, { attachments: options?.attachments }) + } + + async function retry(): Promise { + const currentRetryContext = retryContext.value + const currentError = request.lastError.value + + if (!currentRetryContext || !currentError?.retryable) { + return false + } + + if (conversation.activeConversationId.value !== currentRetryContext.conversationId) { + return false + } + + const activeMessages = conversation.activeConversation.value?.engine.messages.value + if (!activeMessages) { + return false + } + + const failedTurnStartIndex = activeMessages.findIndex( + (message) => message.role === 'user' && getChatMessageTurnId(message) === currentRetryContext.turnId, + ) + + if (failedTurnStartIndex >= 0) { + const preservedUserMessageId = getRuntimeMessageId(activeMessages[failedTurnStartIndex]) + activeMessages.splice(failedTurnStartIndex) + clearFailureState() + resendMessage(currentRetryContext.userContent, { preserveUserMessageId: preservedUserMessageId }) + return true + } + + clearFailureState() + resendMessage(currentRetryContext.userContent) + return true + } + + async function regenerate(messageIndex?: number): Promise { + const currentConversationId = conversation.activeConversationId.value + const activeMessages = conversation.activeConversation.value?.engine.messages.value + + if (!currentConversationId || !activeMessages?.length) { + return false + } + + const targetAssistantIndex = + typeof messageIndex === 'number' + ? messageIndex + : [...activeMessages] + .map((message, index) => ({ message, index })) + .reverse() + .find(({ message }) => message.role === 'assistant')?.index + + if ( + targetAssistantIndex === undefined || + targetAssistantIndex < 0 || + targetAssistantIndex >= activeMessages.length + ) { + return false + } + + const userMessageIndex = findPreviousUserMessageIndex(activeMessages, targetAssistantIndex) + if (userMessageIndex < 0) { + return false + } + + const userMessage = activeMessages[userMessageIndex] + if (!userMessage || typeof userMessage.content !== 'string' || !userMessage.content.trim()) { + return false + } + + const preservedUserMessageId = getRuntimeMessageId(userMessage) + + clearFailureState() + clearPendingEditRollback() + editRollbackContext.value = { + conversationId: currentConversationId, + messageIndex: userMessageIndex, + removedMessages: cloneMessages(activeMessages.slice(userMessageIndex)), + } + + activeMessages.splice(userMessageIndex) + resendMessage(userMessage.content, { preserveUserMessageId: preservedUserMessageId }) + return true + } + + return { + conversations: conversation.conversations, + activeConversationId: conversation.activeConversationId, + activeConversation: conversation.activeConversation, + createConversation: conversation.createConversation, + switchConversation: conversation.switchConversation, + deleteConversation: conversation.deleteConversation, + updateConversationTitle: conversation.updateConversationTitle, + abortActiveRequest: conversation.abortActiveRequest, + messages, + status: request.status, + lastError: request.lastError, + sendMessage, + updateResponseProvider: request.updateResponseProvider, + abort: request.abort, + retry, + regenerate, + runtime: { + activeEngine, + requestState: runtimeRequestState, + processingState: runtimeProcessingState, + isProcessing: runtimeIsProcessing, + clear: conversation.clear, + saveMessages: conversation.saveMessages, + }, + startEditMessage: messageActions.startEditMessage, + cancelEditMessage: messageActions.cancelEditMessage, + isMessageEditing: messageActions.isMessageEditing, + editMessage: messageActions.editMessage, + } +} diff --git a/packages/chat/src/runtime/engine/useChatMessages.ts b/packages/chat/src/runtime/engine/useChatMessages.ts new file mode 100644 index 000000000..3a3667fa9 --- /dev/null +++ b/packages/chat/src/runtime/engine/useChatMessages.ts @@ -0,0 +1,87 @@ +import type { ComputedRef } from 'vue' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import { getRuntimeMessageId } from '@/runtime/core/messageIdentity' +import { isChatMessageEditing, setChatMessageEditing } from '../engine/chatMessageState' + +interface UseChatMessagesOptions { + messages: ComputedRef + resendMessage: (content: string, options?: { preserveUserMessageId?: string }) => void + onOptimisticEdit?: (payload: { messageIndex: number; removedMessages: ChatMessage[]; newContent: string }) => void +} + +export function cloneValue(value: T): T { + if (typeof structuredClone === 'function') { + try { + return structuredClone(value) + } catch { + // Fall through to the recursive clone below when structuredClone cannot serialize the value. + } + } + + if (Array.isArray(value)) { + return value.map((item) => cloneValue(item)) as T + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [key, cloneValue(item)]), + ) as T + } + + return value +} + +export function cloneMessages(messages: ChatMessage[]): ChatMessage[] { + return messages.map((message) => cloneValue(message)) +} + +export function useChatMessages(options: UseChatMessagesOptions) { + function startEditMessage(messageIndex: number): void { + const message = options.messages.value[messageIndex] + if (!message) { + console.warn(`[useChatMessages] startEditMessage: invalid messageIndex ${messageIndex}`) + return + } + + setChatMessageEditing(message, true) + } + + function cancelEditMessage(messageIndex: number): void { + const message = options.messages.value[messageIndex] + setChatMessageEditing(message ?? null, false) + } + + function isMessageEditing(messageIndex: number): boolean { + return isChatMessageEditing(options.messages.value[messageIndex]) + } + + function editMessage(messageIndex: number, newContent: string): void { + const currentMessages = options.messages.value + if (messageIndex < 0 || messageIndex >= currentMessages.length) { + console.warn(`[useChatMessages] editMessage: invalid messageIndex ${messageIndex}`) + return + } + + if (!newContent.trim()) { + console.warn('[useChatMessages] editMessage: newContent cannot be empty') + return + } + + const message = currentMessages[messageIndex] + const preservedUserMessageId = getRuntimeMessageId(message) + options.onOptimisticEdit?.({ + messageIndex, + removedMessages: cloneMessages(currentMessages.slice(messageIndex)), + newContent, + }) + currentMessages.splice(messageIndex) + options.resendMessage(newContent, { preserveUserMessageId: preservedUserMessageId }) + } + + return { + startEditMessage, + cancelEditMessage, + isMessageEditing, + editMessage, + } +} diff --git a/packages/chat/src/runtime/engine/useChatRequest.ts b/packages/chat/src/runtime/engine/useChatRequest.ts new file mode 100644 index 000000000..e0e8a909e --- /dev/null +++ b/packages/chat/src/runtime/engine/useChatRequest.ts @@ -0,0 +1,190 @@ +import { computed, shallowRef, watchEffect } from 'vue' +import type { ComputedRef, ShallowRef } from 'vue' +import type { UseConversationReturn } from '@opentiny/tiny-robot-kit' +import type { ChatErrorInfo, ChatStatus, ResponseProvider, UseMessageResponseProvider } from '@/types' +import { ChatProviderError } from '@/runtime/transport/openaiCompatibleTransport' + +interface UseChatRequestOptions { + conversation: Pick + responseProviderRef: ShallowRef +} + +function extractStatusCode(message: string): number | undefined { + const matched = message.match(/\b(401|403|408|429|5\d{2})\b/) + return matched ? Number(matched[1]) : undefined +} + +function normalizeChatError(error: unknown): ChatErrorInfo { + const normalizedError = error instanceof Error ? error : new Error(String(error)) + const message = normalizedError.message || 'Unknown error' + const httpStatus = + error instanceof ChatProviderError + ? error.httpStatus + : typeof (error as { httpStatus?: unknown })?.httpStatus === 'number' + ? (error as { httpStatus: number }).httpStatus + : typeof (error as { statusCode?: unknown })?.statusCode === 'number' + ? (error as { statusCode: number }).statusCode + : extractStatusCode(message) + const statusCode = httpStatus + const code = + error instanceof ChatProviderError + ? error.code + : typeof (error as { code?: unknown })?.code === 'string' + ? (error as { code: string }).code + : undefined + const providerId = + error instanceof ChatProviderError + ? error.providerId + : typeof (error as { providerId?: unknown })?.providerId === 'string' + ? (error as { providerId: string }).providerId + : undefined + const lowerCasedMessage = message.toLowerCase() + const retryable = + error instanceof ChatProviderError && typeof error.retryable === 'boolean' ? error.retryable : undefined + + if (statusCode === 401 || statusCode === 403 || lowerCasedMessage.includes('api key') || code === 'invalid_api_key') { + return { + type: 'auth', + message, + retryable: retryable ?? false, + httpStatus, + statusCode, + code, + providerId, + originalError: error, + } + } + + if (statusCode === 429 || lowerCasedMessage.includes('rate limit') || code === 'rate_limit_exceeded') { + return { + type: 'rate_limit', + message, + retryable: retryable ?? true, + httpStatus, + statusCode, + code, + providerId, + originalError: error, + } + } + + if (statusCode && statusCode >= 500) { + return { + type: 'server', + message, + retryable: retryable ?? true, + httpStatus, + statusCode, + code, + providerId, + originalError: error, + } + } + + if (lowerCasedMessage.includes('timeout') || lowerCasedMessage.includes('econnaborted')) { + return { + type: 'timeout', + message, + retryable: retryable ?? true, + httpStatus, + statusCode, + code, + providerId, + originalError: error, + } + } + + if ( + lowerCasedMessage.includes('failed to fetch') || + lowerCasedMessage.includes('network') || + lowerCasedMessage.includes('fetch') + ) { + return { + type: 'network', + message, + retryable: retryable ?? true, + httpStatus, + statusCode, + code, + providerId, + originalError: error, + } + } + + if (lowerCasedMessage.includes('provider')) { + return { + type: 'provider', + message, + retryable: retryable ?? true, + httpStatus, + statusCode, + code, + providerId, + originalError: error, + } + } + + return { + type: 'unknown', + message, + retryable: retryable ?? true, + httpStatus, + statusCode, + code, + providerId, + originalError: error, + } +} + +export function useChatRequest(options: UseChatRequestOptions) { + const lastError = shallowRef(null) + + const status = computed(() => { + const engine = options.conversation.activeConversation.value?.engine + if (!engine) return 'ready' + + const requestState = engine.requestState.value + const processingState = engine.processingState.value + + if (requestState === 'error') return 'error' + if (requestState === 'processing') { + return processingState === 'completing' ? 'streaming' : 'submitted' + } + + return 'ready' + }) + + function updateResponseProvider(provider: ResponseProvider): void { + options.responseProviderRef.value = provider as UseMessageResponseProvider + } + + function captureError(error: unknown): ChatErrorInfo { + const normalizedError = normalizeChatError(error) + lastError.value = normalizedError + return normalizedError + } + + function clearLastError(): void { + lastError.value = null + } + + watchEffect(() => { + const engine = options.conversation.activeConversation.value?.engine + if (engine) { + engine.responseProvider.value = options.responseProviderRef.value + } + }) + + async function abort(): Promise { + await options.conversation.abortActiveRequest() + } + + return { + status, + lastError: computed(() => lastError.value) as ComputedRef, + updateResponseProvider, + captureError, + clearLastError, + abort, + } +} diff --git a/packages/chat/src/runtime/features/featureTypes.ts b/packages/chat/src/runtime/features/featureTypes.ts new file mode 100644 index 000000000..0d4e85154 --- /dev/null +++ b/packages/chat/src/runtime/features/featureTypes.ts @@ -0,0 +1,100 @@ +import type { PromptProps } from '@opentiny/tiny-robot' +import type { ChatAttachmentsFeaturePreset, ChatSenderActionsFeaturePreset, TrChatPresetOverrides } from '@/types' + +export type ChatFeatureInput = boolean | ({ enabled?: boolean } & TConfig) + +export interface ChatHistoryFeatureOptions { + props?: TrChatPresetOverrides['historyProps'] +} + +export type ChatAttachmentsFeatureConfig = ChatFeatureInput +export type ChatSenderActionsFeatureConfig = ChatFeatureInput +export interface ChatWelcomePromptsFeatureOptions { + welcome?: PromptProps[] +} +export type ChatWelcomePromptsFeatureConfig = ChatFeatureInput +export interface ChatMcpFeatureOptions { + manager?: TrChatPresetOverrides['mcpManager'] +} +export type ChatMcpFeatureConfig = ChatFeatureInput +export type ChatHistoryFeatureConfig = ChatFeatureInput +export type ChatFeedbackFeatureConfig = ChatFeatureInput + +export interface ChatFeatureConfigMap { + attachments?: ChatAttachmentsFeatureConfig + senderActions?: ChatSenderActionsFeatureConfig + welcomePrompts?: ChatWelcomePromptsFeatureConfig + mcp?: ChatMcpFeatureConfig + history?: ChatHistoryFeatureConfig + feedback?: ChatFeedbackFeatureConfig +} + +export type BuiltInChatFeatureKey = 'attachments' | 'senderActions' | 'welcomePrompts' | 'mcp' | 'history' | 'feedback' + +export type ChatFeaturePresetProps = Partial< + Pick< + TrChatPresetOverrides, + | 'attachmentsFeature' + | 'senderActionsFeature' + | 'prompts' + | 'mcpManager' + | 'showHistory' + | 'historyProps' + | 'showFeedback' + > +> + +export interface ChatAttachmentsFeatureResolution { + key: 'attachments' + enabled: boolean + config?: Exclude + presetProps: ChatFeaturePresetProps +} + +export interface ChatSenderActionsFeatureResolution { + key: 'senderActions' + enabled: boolean + config?: Exclude + presetProps: ChatFeaturePresetProps +} + +export interface ChatWelcomePromptsFeatureResolution { + key: 'welcomePrompts' + enabled: boolean + config?: Exclude + presetProps: ChatFeaturePresetProps +} + +export interface ChatMcpFeatureResolution { + key: 'mcp' + enabled: boolean + config?: Exclude + presetProps: ChatFeaturePresetProps +} + +export interface ChatHistoryFeatureResolution { + key: 'history' + enabled: boolean + config?: Exclude + presetProps: ChatFeaturePresetProps +} + +export interface ChatFeedbackFeatureResolution { + key: 'feedback' + enabled: boolean + config?: Exclude + presetProps: ChatFeaturePresetProps +} + +export interface ResolvedChatFeatures { + entries: { + attachments: ChatAttachmentsFeatureResolution + senderActions: ChatSenderActionsFeatureResolution + welcomePrompts: ChatWelcomePromptsFeatureResolution + mcp: ChatMcpFeatureResolution + history: ChatHistoryFeatureResolution + feedback: ChatFeedbackFeatureResolution + } + enabledKeys: BuiltInChatFeatureKey[] + presetProps: ChatFeaturePresetProps +} diff --git a/packages/chat/src/runtime/features/index.ts b/packages/chat/src/runtime/features/index.ts new file mode 100644 index 000000000..ffc6d3dff --- /dev/null +++ b/packages/chat/src/runtime/features/index.ts @@ -0,0 +1,22 @@ +export { CHAT_FEATURE_REGISTRY, isChatFeatureExplicitlyDisabled, resolveChatFeatures } from './registry' +export type { + ChatAttachmentsFeatureConfig, + ChatAttachmentsFeatureResolution, + BuiltInChatFeatureKey, + ChatFeatureConfigMap, + ChatFeatureInput, + ChatMcpFeatureConfig, + ChatMcpFeatureResolution, + ChatFeaturePresetProps, + ChatFeedbackFeatureConfig, + ChatFeedbackFeatureResolution, + ChatHistoryFeatureConfig, + ChatHistoryFeatureOptions, + ChatHistoryFeatureResolution, + ChatSenderActionsFeatureConfig, + ChatSenderActionsFeatureResolution, + ChatWelcomePromptsFeatureConfig, + ChatWelcomePromptsFeatureOptions, + ChatWelcomePromptsFeatureResolution, + ResolvedChatFeatures, +} from './featureTypes' diff --git a/packages/chat/src/runtime/features/registry.ts b/packages/chat/src/runtime/features/registry.ts new file mode 100644 index 000000000..fd44fd1be --- /dev/null +++ b/packages/chat/src/runtime/features/registry.ts @@ -0,0 +1,272 @@ +import type { TrChatPresetOverrides } from '@/types' +import type { + BuiltInChatFeatureKey, + ChatAttachmentsFeatureResolution, + ChatFeatureConfigMap, + ChatFeatureInput, + ChatMcpFeatureConfig, + ChatMcpFeatureResolution, + ChatFeaturePresetProps, + ChatFeedbackFeatureConfig, + ChatFeedbackFeatureResolution, + ChatHistoryFeatureConfig, + ChatHistoryFeatureResolution, + ChatSenderActionsFeatureResolution, + ChatWelcomePromptsFeatureResolution, + ResolvedChatFeatures, +} from './featureTypes' + +interface ChatFeatureDefinition { + key: TKey + resolve: (config: TConfig | undefined) => TResolution +} + +export function isChatFeatureExplicitlyDisabled(config: ChatFeatureInput | undefined): boolean { + if (config === false) { + return true + } + + if (typeof config === 'object' && config !== null) { + return config.enabled === false + } + + return false +} + +function isFeatureEnabled(config: ChatFeatureInput | undefined): boolean { + if (config === undefined) { + return false + } + + if (typeof config === 'boolean') { + return config + } + + return config.enabled ?? true +} + +function mergePresetProps(...parts: ChatFeaturePresetProps[]): ChatFeaturePresetProps { + return Object.assign({}, ...parts) +} + +const attachmentsFeature: ChatFeatureDefinition< + 'attachments', + ChatFeatureConfigMap['attachments'], + ChatAttachmentsFeatureResolution +> = { + key: 'attachments', + resolve(config) { + const enabled = isFeatureEnabled(config) + const resolvedConfig = typeof config === 'object' && config !== null ? config : undefined + + return { + key: 'attachments', + enabled, + config: resolvedConfig, + presetProps: enabled + ? { + attachmentsFeature: { + enabled: true, + upload: { + enabled: resolvedConfig?.upload?.enabled ?? true, + accept: resolvedConfig?.upload?.accept ?? '*', + multiple: resolvedConfig?.upload?.multiple ?? true, + maxCount: resolvedConfig?.upload?.maxCount, + maxSize: resolvedConfig?.upload?.maxSize, + tooltip: resolvedConfig?.upload?.tooltip, + tooltipPlacement: resolvedConfig?.upload?.tooltipPlacement ?? 'top', + }, + list: { + variant: resolvedConfig?.list?.variant ?? 'card', + wrap: resolvedConfig?.list?.wrap ?? true, + actions: resolvedConfig?.list?.actions, + fileIcons: resolvedConfig?.list?.fileIcons, + fileMatchers: resolvedConfig?.list?.fileMatchers, + disabled: resolvedConfig?.list?.disabled, + }, + }, + } + : {}, + } + }, +} + +const senderActionsFeature: ChatFeatureDefinition< + 'senderActions', + ChatFeatureConfigMap['senderActions'], + ChatSenderActionsFeatureResolution +> = { + key: 'senderActions', + resolve(config) { + const enabled = isFeatureEnabled(config) + const resolvedConfig = typeof config === 'object' && config !== null ? config : undefined + + return { + key: 'senderActions', + enabled, + config: resolvedConfig, + presetProps: enabled + ? { + senderActionsFeature: { + enabled: true, + upload: + resolvedConfig?.upload === undefined + ? undefined + : { + enabled: resolvedConfig.upload.enabled ?? true, + accept: resolvedConfig.upload.accept ?? '*', + multiple: resolvedConfig.upload.multiple ?? true, + maxCount: resolvedConfig.upload.maxCount, + maxSize: resolvedConfig.upload.maxSize, + tooltip: resolvedConfig.upload.tooltip, + tooltipPlacement: resolvedConfig.upload.tooltipPlacement ?? 'top', + }, + voice: + resolvedConfig?.voice === undefined + ? undefined + : { + enabled: resolvedConfig.voice.enabled ?? true, + tooltip: resolvedConfig.voice.tooltip, + tooltipPlacement: resolvedConfig.voice.tooltipPlacement ?? 'top', + size: resolvedConfig.voice.size, + speechConfig: resolvedConfig.voice.speechConfig, + autoInsert: resolvedConfig.voice.autoInsert, + onButtonClick: resolvedConfig.voice.onButtonClick, + icon: resolvedConfig.voice.icon, + recordingIcon: resolvedConfig.voice.recordingIcon, + }, + wordCount: resolvedConfig?.wordCount ?? false, + defaultActions: resolvedConfig?.defaultActions, + }, + } + : {}, + } + }, +} + +const welcomePromptsFeature: ChatFeatureDefinition< + 'welcomePrompts', + ChatFeatureConfigMap['welcomePrompts'], + ChatWelcomePromptsFeatureResolution +> = { + key: 'welcomePrompts', + resolve(config) { + const hasConfig = config !== undefined + const enabled = isFeatureEnabled(config) + const resolvedConfig = typeof config === 'object' && config !== null ? config : undefined + const welcomePrompts = resolvedConfig?.welcome + + return { + key: 'welcomePrompts', + enabled, + config: resolvedConfig, + presetProps: enabled + ? welcomePrompts?.length + ? { + prompts: welcomePrompts, + } + : {} + : hasConfig + ? { + prompts: [], + } + : {}, + } + }, +} + +const mcpFeature: ChatFeatureDefinition<'mcp', ChatMcpFeatureConfig, ChatMcpFeatureResolution> = { + key: 'mcp', + resolve(config) { + const enabled = isFeatureEnabled(config) + const resolvedConfig = typeof config === 'object' && config !== null ? config : undefined + + return { + key: 'mcp', + enabled, + config: resolvedConfig, + presetProps: + enabled && resolvedConfig?.manager + ? { + mcpManager: resolvedConfig.manager, + } + : {}, + } + }, +} + +const historyFeature: ChatFeatureDefinition<'history', ChatHistoryFeatureConfig, ChatHistoryFeatureResolution> = { + key: 'history', + resolve(config) { + const enabled = isFeatureEnabled(config) + const resolvedConfig = typeof config === 'object' && config !== null ? config : undefined + + return { + key: 'history', + enabled, + config: resolvedConfig, + presetProps: enabled + ? { + showHistory: true, + historyProps: resolvedConfig?.props as TrChatPresetOverrides['historyProps'], + } + : {}, + } + }, +} + +const feedbackFeature: ChatFeatureDefinition<'feedback', ChatFeedbackFeatureConfig, ChatFeedbackFeatureResolution> = { + key: 'feedback', + resolve(config) { + const enabled = isFeatureEnabled(config) + const resolvedConfig = typeof config === 'object' && config !== null ? config : undefined + + return { + key: 'feedback', + enabled, + config: resolvedConfig, + presetProps: enabled + ? { + showFeedback: true, + } + : {}, + } + }, +} + +export const CHAT_FEATURE_REGISTRY = { + attachments: attachmentsFeature, + senderActions: senderActionsFeature, + welcomePrompts: welcomePromptsFeature, + mcp: mcpFeature, + history: historyFeature, + feedback: feedbackFeature, +} as const + +export function resolveChatFeatures(config: ChatFeatureConfigMap | undefined): ResolvedChatFeatures { + const entries = { + attachments: CHAT_FEATURE_REGISTRY.attachments.resolve(config?.attachments), + senderActions: CHAT_FEATURE_REGISTRY.senderActions.resolve(config?.senderActions), + welcomePrompts: CHAT_FEATURE_REGISTRY.welcomePrompts.resolve(config?.welcomePrompts), + mcp: CHAT_FEATURE_REGISTRY.mcp.resolve(config?.mcp), + history: CHAT_FEATURE_REGISTRY.history.resolve(config?.history), + feedback: CHAT_FEATURE_REGISTRY.feedback.resolve(config?.feedback), + } + + const enabledKeys = Object.values(entries) + .filter((entry) => entry.enabled) + .map((entry) => entry.key) + + return { + entries, + enabledKeys, + presetProps: mergePresetProps( + entries.attachments.presetProps, + entries.senderActions.presetProps, + entries.welcomePrompts.presetProps, + entries.mcp.presetProps, + entries.history.presetProps, + entries.feedback.presetProps, + ), + } +} diff --git a/packages/chat/src/runtime/transport/index.ts b/packages/chat/src/runtime/transport/index.ts new file mode 100644 index 000000000..38df6b23f --- /dev/null +++ b/packages/chat/src/runtime/transport/index.ts @@ -0,0 +1 @@ +export { createOpenAICompatibleResponseProvider } from './openaiCompatibleTransport' diff --git a/packages/chat/src/runtime/transport/openaiCompatibleTransport.ts b/packages/chat/src/runtime/transport/openaiCompatibleTransport.ts new file mode 100644 index 000000000..cbf767d09 --- /dev/null +++ b/packages/chat/src/runtime/transport/openaiCompatibleTransport.ts @@ -0,0 +1,227 @@ +import { sseStreamToGenerator } from '@opentiny/tiny-robot-kit' +import type { ChatCompletion, MessageRequestBody } from '@opentiny/tiny-robot-kit' +import { isImageAttachment, type AttachmentLike } from '@/shared/attachments' +import type { ResponseProvider } from '@/types' + +export interface ChatProviderErrorOptions { + providerId?: string + message: string + httpStatus?: number + code?: string + retryable?: boolean + cause?: unknown +} + +export class ChatProviderError extends Error { + providerId?: string + httpStatus?: number + statusCode?: number + code?: string + retryable?: boolean + cause?: unknown + + constructor(options: ChatProviderErrorOptions) { + super(options.message) + this.name = 'ChatProviderError' + this.providerId = options.providerId + this.httpStatus = options.httpStatus + this.statusCode = options.httpStatus + this.code = options.code + this.retryable = options.retryable + if (options.cause !== undefined) { + this.cause = options.cause + } + } +} + +export interface OpenAICompatibleResponseProviderOptions { + providerId: string + model: string + endpoint?: string + baseURL?: string + apiPath?: string + systemPrompt?: string + temperature?: number + maxTokens?: number + headers?: Record + credentials?: RequestCredentials +} + +function resolveEndpoint(options: OpenAICompatibleResponseProviderOptions): string { + if (options.endpoint) { + return options.endpoint + } + + if (!options.baseURL) { + throw new Error('[createOpenAICompatibleResponseProvider] Either endpoint or baseURL must be provided') + } + + const baseURL = options.baseURL.replace(/\/+$/, '') + const apiPath = options.apiPath ?? '/chat/completions' + const normalizedApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}` + + return `${baseURL}${normalizedApiPath}` +} + +function toMultimodalContent(message: Partial>): string | Array> { + const attachments = message.attachments as AttachmentLike[] | undefined + if (!Array.isArray(attachments) || attachments.length === 0) { + return (message.content as string) ?? '' + } + + const parts: Array> = [{ type: 'text', text: (message.content as string) ?? '' }] + + for (const attachment of attachments) { + const url = attachment.url + if (!url) continue + + if (isImageAttachment(attachment)) { + parts.push({ type: 'image_url', image_url: { url } }) + } + // Future: video_url, file, etc. + } + + return parts +} + +function buildOpenAICompatibleRequestBody( + options: OpenAICompatibleResponseProviderOptions, + requestBody: MessageRequestBody, +) { + const { messages: requestMessages, ...extraRequestFields } = requestBody + + const serializedMessages = requestMessages.map((msg) => { + const hasAttachments = + Array.isArray((msg as Record).attachments) && + ((msg as Record).attachments as unknown[]).length > 0 + + if (!hasAttachments) return msg + + const { attachments: _, ...rest } = msg as Record + return { ...rest, content: toMultimodalContent(msg as Record) } + }) + + const messages = options.systemPrompt + ? [{ role: 'system', content: options.systemPrompt }, ...serializedMessages] + : serializedMessages + + const body: Record = { + ...extraRequestFields, + model: options.model, + messages, + stream: true, + stream_options: { include_usage: true }, + } + + if (options.temperature !== undefined) { + body.temperature = options.temperature + } + + if (options.maxTokens !== undefined) { + body.max_tokens = options.maxTokens + } + + return body +} + +async function parseProviderErrorResponse(response: Response) { + const text = await response.text() + const contentType = response.headers.get('content-type') ?? '' + + if (contentType.includes('application/json')) { + try { + const payload = JSON.parse(text) as + | { + error?: { + message?: string + code?: string + } + message?: string + code?: string + } + | undefined + + return { + message: payload?.error?.message ?? payload?.message ?? text, + code: payload?.error?.code ?? payload?.code, + } + } catch { + return { + message: text, + code: undefined, + } + } + } + + return { + message: text, + code: undefined, + } +} + +function inferRetryable(httpStatus?: number, code?: string) { + if (httpStatus === 401 || httpStatus === 403 || code === 'invalid_api_key') { + return false + } + + if (httpStatus === 429 || code === 'rate_limit_exceeded') { + return true + } + + if (httpStatus && httpStatus >= 500) { + return true + } + + return true +} + +export function createOpenAICompatibleResponseProvider( + options: OpenAICompatibleResponseProviderOptions, +): ResponseProvider { + const endpoint = resolveEndpoint(options) + const { headers = {}, credentials } = options + + return async function* (requestBody, abortSignal) { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + ...headers, + }, + body: JSON.stringify(buildOpenAICompatibleRequestBody(options, requestBody)), + signal: abortSignal, + credentials, + }) + + if (!response.ok) { + const parsed = await parseProviderErrorResponse(response) + + throw new ChatProviderError({ + providerId: options.providerId, + message: `${options.providerId} API error ${response.status}: ${parsed.message}`, + httpStatus: response.status, + code: parsed.code, + retryable: inferRetryable(response.status, parsed.code), + }) + } + + let pendingChunk: ChatCompletion | undefined + + for await (const chunk of sseStreamToGenerator(response, { signal: abortSignal })) { + const hasChoices = Array.isArray(chunk.choices) && chunk.choices.length > 0 + + if (!hasChoices && chunk.usage != null) { + // usage-only chunk (DashScope stream_options pattern): + // merge usage into the buffered previous chunk and yield together + yield pendingChunk != null ? { ...pendingChunk, usage: chunk.usage } : chunk + pendingChunk = undefined + } else { + if (pendingChunk) yield pendingChunk + pendingChunk = chunk + } + } + + if (pendingChunk) yield pendingChunk + } +} diff --git a/packages/chat/src/shared/attachments.ts b/packages/chat/src/shared/attachments.ts new file mode 100644 index 000000000..010869049 --- /dev/null +++ b/packages/chat/src/shared/attachments.ts @@ -0,0 +1,86 @@ +/** + * 附件类型检测与判断工具。 + */ + +// --------------------------------------------------------------------------- +// 文件类型检测 +// --------------------------------------------------------------------------- + +const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'tiff', 'tif', 'heic']) +const PDF_EXTENSIONS = new Set(['pdf']) +const WORD_EXTENSIONS = new Set(['doc', 'docx']) +const EXCEL_EXTENSIONS = new Set(['xls', 'xlsx']) +const PPT_EXTENSIONS = new Set(['ppt', 'pptx']) + +const MIME_TYPE_MAP: Record = { + 'application/pdf': 'pdf', + 'application/msword': 'word', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'word', + 'application/vnd.ms-excel': 'excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'excel', + 'application/vnd.ms-powerpoint': 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'ppt', +} + +const EXTENSION_MAP: Array<[Set, string]> = [ + [IMAGE_EXTENSIONS, 'image'], + [PDF_EXTENSIONS, 'pdf'], + [WORD_EXTENSIONS, 'word'], + [EXCEL_EXTENSIONS, 'excel'], + [PPT_EXTENSIONS, 'ppt'], +] + +function getExtension(name: string): string { + return (name.split('.').pop() ?? '').toLowerCase() +} + +/** + * 根据 File 对象或文件名字符串检测文件类型。 + * 返回 'image' | 'pdf' | 'word' | 'excel' | 'ppt' | 'other' + */ +export function detectFileType(file: File | string): string { + if (typeof file !== 'string') { + // File 对象:先查 MIME,再查扩展名 + const mime = file.type ?? '' + if (mime.startsWith('image/')) return 'image' + if (MIME_TYPE_MAP[mime]) return MIME_TYPE_MAP[mime] + // fallback 到文件名 + const ext = getExtension(file.name) + for (const [set, type] of EXTENSION_MAP) { + if (set.has(ext)) return type + } + return 'other' + } + + // 字符串:按扩展名匹配 + const ext = getExtension(file) + for (const [set, type] of EXTENSION_MAP) { + if (set.has(ext)) return type + } + return 'other' +} + +// --------------------------------------------------------------------------- +// 附件图片判断 +// --------------------------------------------------------------------------- + +export interface AttachmentLike { + fileType?: string + rawFile?: File | Record + name?: string + url?: string +} + +/** + * 判断附件是否为图片。 + * 优先级:fileType > rawFile MIME > 文件名后缀 + * 对持久化后 rawFile 退化为普通对象的情况做了防御。 + */ +export function isImageAttachment(attachment: AttachmentLike): boolean { + if (attachment.fileType === 'image') return true + if (attachment.rawFile instanceof File && attachment.rawFile.type.startsWith('image/')) return true + if (typeof attachment.name === 'string' && attachment.name) { + return detectFileType(attachment.name) === 'image' + } + return false +} diff --git a/packages/chat/src/shared/context/chatUiContext.ts b/packages/chat/src/shared/context/chatUiContext.ts new file mode 100644 index 000000000..4d6449d3f --- /dev/null +++ b/packages/chat/src/shared/context/chatUiContext.ts @@ -0,0 +1,409 @@ +import { computed, onScopeDispose, ref, toValue, watch, type ComputedRef, type MaybeRefOrGetter, type Ref } from 'vue' +import type { + ChatShellVariant, + ChatWorkspaceRegionCollapseMode, + ChatWorkspaceRegionWidth, + ChatWorkspaceShellConfig, + ChatWorkspaceRuntime, +} from '@/types' + +export type ChatHistoryDisplayMode = 'drawer' | 'surface' + +export interface ChatWorkspaceRegionState { + visible: Ref + collapsed: Ref + width: Ref + collapseMode: Ref + open: () => void + close: () => void + toggle: () => void + collapse: () => void + expand: () => void + setWidth: (width: number) => void +} + +export interface ChatWorkspaceState { + enabled: ComputedRef + variant: Ref + isMobile: Ref + setResponsiveHost: (element: HTMLElement | null) => void + left: ChatWorkspaceRegionState + right: ChatWorkspaceRegionState +} + +export interface ChatUiContextValue { + workspace: ChatWorkspaceState + history: { + visible: ComputedRef + display: Ref + open: () => void + close: () => void + toggle: () => void + } +} + +export interface CreateChatUiContextOptions { + historyDisplay?: ChatHistoryDisplayMode + historyVisible?: boolean + closableHistory?: boolean + shell?: MaybeRefOrGetter + mobileBreakpoint?: string + workspaceRuntime?: ChatWorkspaceRuntime +} + +function resolveMaxWidthBreakpoint(query: string) { + const match = query.match(/max-width:\s*(\d+)px/i) + if (!match) { + return null + } + + return Number(match[1]) +} + +function createWorkspaceRegionState(options: { + visible: boolean + collapsed: boolean + width?: number | 'sm' | 'md' | 'lg' + collapseMode?: ChatWorkspaceRegionCollapseMode +}) { + const visible = ref(options.visible) + const collapsed = ref(options.collapsed) + const width = ref(options.width) + const collapseMode = ref(options.collapseMode ?? 'hidden') + + return { + visible, + collapsed, + width, + collapseMode, + open: () => { + visible.value = true + collapsed.value = false + }, + close: () => { + visible.value = false + }, + toggle: () => { + visible.value = !visible.value + if (visible.value) { + collapsed.value = false + } + }, + collapse: () => { + collapsed.value = true + if (collapseMode.value === 'hidden') { + visible.value = false + } + }, + expand: () => { + visible.value = true + collapsed.value = false + }, + setWidth: (nextWidth: number) => { + width.value = nextWidth + }, + } +} + +export function createChatUiContext(options: CreateChatUiContextOptions = {}): ChatUiContextValue { + if (options.workspaceRuntime) { + const display = ref('drawer') + + return { + workspace: { + enabled: options.workspaceRuntime.enabled as ComputedRef, + variant: options.workspaceRuntime.variant as Ref, + isMobile: options.workspaceRuntime.isMobile as Ref, + setResponsiveHost: options.workspaceRuntime.setResponsiveHost, + left: options.workspaceRuntime.left, + right: options.workspaceRuntime.right, + }, + history: { + visible: options.workspaceRuntime.historyVisible as ComputedRef, + display, + open: options.workspaceRuntime.openHistory, + close: options.workspaceRuntime.closeHistory, + toggle: options.workspaceRuntime.toggleHistory, + }, + } + } + + const resolvedShell = computed(() => toValue(options.shell)) + const variant = ref(resolvedShell.value?.variant ?? 'stacked') + const enabled = computed(() => variant.value === 'workspace') + const isMobile = ref(false) + const display = ref(options.historyDisplay ?? 'drawer') + const legacyHistoryVisible = ref(options.historyVisible ?? display.value === 'surface') + const closableHistory = options.closableHistory ?? display.value !== 'surface' + + const leftCollapseMode = resolvedShell.value?.leftRegion?.collapseMode ?? 'rail' + const rightCollapseMode = resolvedShell.value?.rightRegion?.collapseMode ?? 'hidden' + const leftDefaultOpen = resolvedShell.value?.leftRegion?.defaultOpen !== false + const rightDefaultOpen = resolvedShell.value?.rightRegion?.defaultOpen === true + const mobileBreakpoint = options.mobileBreakpoint ?? '(max-width: 900px)' + const mobileBreakpointWidth = resolveMaxWidthBreakpoint(mobileBreakpoint) + + const left = createWorkspaceRegionState({ + visible: leftDefaultOpen, + collapsed: !leftDefaultOpen && leftCollapseMode === 'rail', + width: resolvedShell.value?.leftRegion?.width, + collapseMode: leftCollapseMode, + }) + const right = createWorkspaceRegionState({ + visible: rightDefaultOpen, + collapsed: !rightDefaultOpen, + width: resolvedShell.value?.rightRegion?.width, + collapseMode: rightCollapseMode, + }) + + function syncLeftRegionOpenState(defaultOpen = resolvedShell.value?.leftRegion?.defaultOpen !== false) { + if (defaultOpen) { + left.visible.value = true + left.collapsed.value = false + return + } + + left.collapsed.value = left.collapseMode.value === 'rail' + left.visible.value = left.collapseMode.value === 'rail' + } + + function syncRightRegionOpenState(defaultOpen = resolvedShell.value?.rightRegion?.defaultOpen === true) { + if (defaultOpen) { + right.visible.value = true + right.collapsed.value = false + return + } + + right.collapsed.value = true + right.visible.value = right.collapseMode.value === 'rail' + } + + watch( + () => resolvedShell.value?.variant, + (nextVariant, previousVariant) => { + variant.value = nextVariant ?? 'stacked' + + if (variant.value === 'workspace' && previousVariant !== nextVariant) { + syncLeftRegionOpenState() + syncRightRegionOpenState() + } + }, + { immediate: true }, + ) + + watch( + () => resolvedShell.value?.leftRegion?.width, + (width) => { + left.width.value = width + }, + { immediate: true }, + ) + + watch( + () => resolvedShell.value?.rightRegion?.width, + (width) => { + right.width.value = width + }, + { immediate: true }, + ) + + watch( + () => resolvedShell.value?.leftRegion?.collapseMode, + (nextCollapseMode) => { + left.collapseMode.value = nextCollapseMode ?? 'rail' + + if (left.collapsed.value) { + left.visible.value = left.collapseMode.value === 'rail' + } + }, + { immediate: true }, + ) + + watch( + () => resolvedShell.value?.rightRegion?.collapseMode, + (nextCollapseMode) => { + right.collapseMode.value = nextCollapseMode ?? 'hidden' + + if (right.collapsed.value) { + right.visible.value = right.collapseMode.value === 'rail' + } + }, + { immediate: true }, + ) + + watch( + () => resolvedShell.value?.leftRegion?.defaultOpen, + () => { + syncLeftRegionOpenState() + }, + { immediate: true }, + ) + + watch( + () => resolvedShell.value?.rightRegion?.defaultOpen, + () => { + syncRightRegionOpenState() + }, + { immediate: true }, + ) + + let responsiveHost: HTMLElement | null = null + let responsiveHostObserver: ResizeObserver | null = null + + function syncResponsiveState(viewportMatches = false) { + const hostMatches = + responsiveHost != null && + mobileBreakpointWidth != null && + responsiveHost.getBoundingClientRect().width <= mobileBreakpointWidth + + isMobile.value = viewportMatches || hostMatches + } + + function setResponsiveHost(element: HTMLElement | null) { + if (responsiveHost === element) { + return + } + + responsiveHostObserver?.disconnect() + responsiveHostObserver = null + responsiveHost = element + + if (typeof ResizeObserver === 'undefined' || !responsiveHost) { + syncResponsiveState() + return + } + + responsiveHostObserver = new ResizeObserver(() => { + syncResponsiveState() + }) + responsiveHostObserver.observe(responsiveHost) + syncResponsiveState() + } + + const historyVisible = computed(() => { + if (!enabled.value) { + return legacyHistoryVisible.value + } + + return isMobile.value ? left.visible.value : !left.collapsed.value + }) + + function setLegacyHistoryVisible(nextVisible: boolean) { + if (!closableHistory && !nextVisible) { + return + } + + legacyHistoryVisible.value = nextVisible + } + + function openHistory() { + if (!enabled.value) { + setLegacyHistoryVisible(true) + return + } + + left.expand() + } + + function closeHistory() { + if (!enabled.value) { + setLegacyHistoryVisible(false) + return + } + + if (isMobile.value) { + left.close() + return + } + + if (left.collapseMode.value === 'rail') { + left.collapse() + left.visible.value = true + return + } + + left.close() + } + + function toggleHistory() { + if (historyVisible.value) { + closeHistory() + } else { + openHistory() + } + } + + if (typeof window !== 'undefined') { + const query = window.matchMedia(mobileBreakpoint) + const handleChange = (event: MediaQueryList | MediaQueryListEvent) => { + syncResponsiveState(event.matches) + } + + handleChange(query) + + if ('addEventListener' in query) { + query.addEventListener('change', handleChange) + onScopeDispose(() => { + query.removeEventListener('change', handleChange) + }) + } else { + ;( + query as MediaQueryList & { + addListener: (listener: (event: MediaQueryListEvent) => void) => void + removeListener: (listener: (event: MediaQueryListEvent) => void) => void + } + ).addListener(handleChange) + onScopeDispose(() => { + ;( + query as MediaQueryList & { + removeListener: (listener: (event: MediaQueryListEvent) => void) => void + } + ).removeListener(handleChange) + }) + } + + onScopeDispose(() => { + responsiveHostObserver?.disconnect() + responsiveHostObserver = null + responsiveHost = null + }) + + watch( + isMobile, + (mobile) => { + if (enabled.value) { + display.value = 'drawer' + + if (!mobile) { + left.visible.value = true + if (left.collapseMode.value === 'rail' && historyVisible.value === false) { + left.collapsed.value = true + } + } else { + left.collapsed.value = false + left.visible.value = false + } + } + }, + { immediate: true }, + ) + } + + return { + workspace: { + enabled, + variant, + isMobile, + setResponsiveHost, + left, + right, + }, + history: { + visible: historyVisible, + display, + open: openHistory, + close: closeHistory, + toggle: toggleHistory, + }, + } +} diff --git a/packages/chat/src/shared/context/index.ts b/packages/chat/src/shared/context/index.ts new file mode 100644 index 000000000..6d4e3b2f1 --- /dev/null +++ b/packages/chat/src/shared/context/index.ts @@ -0,0 +1,146 @@ +import { getCurrentInstance, inject, type ComputedRef, type InjectionKey, type Ref } from 'vue' +import type { + ChatAttachmentsFeaturePreset, + ChatBubbleRenderers, + ChatContentLayout, + ChatMessageActionsInput, + ChatMessageActionsMode, + ChatMessageActionPayload, + ChatMessages, + ChatSenderActionsFeaturePreset, + BrandConfig, + ChatAppearanceConfig, + ChatListVariant, + ChatRuntime, + ModelOption, + WelcomeConfig, + ChatWorkspaceShellConfig, +} from '@/types' +import type { UseMcpManagerReturn } from '@/components/mcp/useMcpManager' +import type { UseChatAttachmentsReturn } from '@/components/attachments/useChatAttachments' +import type { BubbleListProps, PromptProps } from '@opentiny/tiny-robot' +import type { ChatUiContextValue } from '@/shared/context/chatUiContext' +import type { UseChatKitReturn } from '@/types/core' + +export { createChatUiContext } from '@/shared/context/chatUiContext' +export type { + ChatHistoryDisplayMode, + ChatUiContextValue, + ChatWorkspaceRegionState, + ChatWorkspaceState, + CreateChatUiContextOptions, +} from '@/shared/context/chatUiContext' + +export const CHAT_KIT_KEY: InjectionKey = Symbol('chatKit') + +export function useRequiredInject( + key: InjectionKey, + dependencyName: string, + componentName?: string, +): NonNullable { + const value = inject(key, null as T | null) + const resolvedComponentName = componentName ?? getCurrentInstance()?.type.name ?? 'AnonymousComponent' + + if (value == null) { + throw new Error(`[${resolvedComponentName}] Missing required ${dependencyName} context`) + } + + return value as NonNullable +} + +export const CHAT_UI_KEY: InjectionKey = Symbol('chatUI') +export const CHAT_RUNTIME_KEY: InjectionKey = Symbol('chatRuntime') + +export interface ChatPageWelcomeInput { + title: string + description?: string + icon?: WelcomeConfig['icon'] | BrandConfig['logo'] + prompts?: PromptProps[] +} + +export interface ChatPageHeaderInput { + title?: string + showHistory?: boolean + showClose?: boolean +} + +export interface ChatPageLayoutInput { + show?: boolean + roleConfigs?: BubbleListProps['roleConfigs'] + contentLayout?: ChatContentLayout + bubbleRenderers?: ChatBubbleRenderers +} + +export interface ChatPageMessageListInput { + autoScroll?: boolean + variant?: ChatListVariant + messageActions?: ChatMessageActionsInput + messageActionsMode?: ChatMessageActionsMode + onActionClick?: (payload: ChatMessageActionPayload) => void + groupStrategy?: BubbleListProps['groupStrategy'] + showFeedback?: boolean +} + +export interface ChatPageModelSelectorInput { + enabled: boolean + models?: ModelOption[] + defaultModel?: string +} + +export interface ChatPageHistoryInput { + enabled: boolean +} + +export interface ChatPageInputsValue { + header?: ChatPageHeaderInput + layout?: ChatPageLayoutInput + welcome?: ChatPageWelcomeInput + messageList?: ChatPageMessageListInput + history?: ChatPageHistoryInput + appearance?: ChatAppearanceConfig + shell?: ChatWorkspaceShellConfig + modelSelector?: ChatPageModelSelectorInput + updateModel?: (model: ModelOption) => void +} + +export const CHAT_PAGE_INPUTS_KEY: InjectionKey> = Symbol('chatPageInputs') + +export const MCP_MANAGER_KEY: InjectionKey = Symbol('mcpManager') + +export const CHAT_ATTACHMENTS_KEY: InjectionKey<{ + manager: UseChatAttachmentsReturn + feature: ChatAttachmentsFeaturePreset +}> = Symbol('chatAttachments') + +export const CHAT_SENDER_ACTIONS_KEY: InjectionKey<{ + feature: ChatSenderActionsFeaturePreset +}> = Symbol('chatSenderActions') + +export const CHAT_MESSAGES_KEY: InjectionKey> = Symbol('chatMessages') + +export const MESSAGE_ACTION_KEY: InjectionKey<((payload: ChatMessageActionPayload) => void) | undefined> = + Symbol('messageAction') + +export const MESSAGE_ACTIONS_KEY: InjectionKey<{ + messageActions: ComputedRef + messageActionsMode: ComputedRef +}> = Symbol('messageActions') + +export const BUBBLE_CONFIG_KEY: InjectionKey<{ + roleConfigs: ComputedRef +}> = Symbol('bubbleConfig') + +export const BUBBLE_LIST_SLOTS = ['prefix', 'suffix', 'after', 'content-footer'] as const + +export const CHAT_HISTORY_KEY: InjectionKey<{ + isManagementMode: Ref + selectedItems: Ref + searchQuery: Ref + toggleItemSelection: (itemId: string) => void + selectAll: (ids: string[]) => void + clearSelection: () => void +}> = Symbol('chatHistory') + +export function useChatPageInputs() { + return inject(CHAT_PAGE_INPUTS_KEY, null) +} diff --git a/packages/chat/src/shared/messages/index.ts b/packages/chat/src/shared/messages/index.ts new file mode 100644 index 000000000..da21f8b10 --- /dev/null +++ b/packages/chat/src/shared/messages/index.ts @@ -0,0 +1,111 @@ +import { computed, inject } from 'vue' +import { CHAT_MESSAGES_KEY } from '@/shared/context' +import type { ChatMessages, ChatMessagesOverrides } from '@/types' + +/** + * Centralized chat-owned copy. + * This is a preparation step for future app-wide i18n, not a runtime locale system. + */ +export const CHAT_MESSAGES: ChatMessages = { + header: { + newChat: '新建对话', + openHistory: '打开历史', + closeHistory: '关闭历史', + close: '关闭', + }, + history: { + newSession: '新建会话', + manage: '管理', + done: '完成', + defaultConversationTitle: '新对话', + searchPlaceholder: '搜索会话...', + deleteSelected: '删除选中', + cancel: '取消', + }, + sender: { + placeholder: '请输入您的问题', + }, + workspace: { + expandLeftSidebar: '展开左侧边栏', + expandRightSidebar: '展开右侧面板', + historyRailLabel: '历史', + previewRailLabel: '预览', + toggleRightPanel: '切换工作区面板', + rightPanelTitle: '扩展工作区', + closeRightPanel: '关闭右侧面板', + }, + modelSelector: { + triggerLabel: '选择模型', + }, + attachments: { + uploadTooltip: '上传附件', + }, + senderActions: { + uploadTooltip: '上传附件', + voiceTooltip: '语音输入', + }, + feedback: { + copy: '复制', + edit: '编辑', + regenerate: '重新生成', + }, + editMessage: { + placeholder: '编辑消息内容...', + cancel: '取消', + save: '保存', + saving: '保存中...', + }, + toolCall: { + running: '正在调用', + success: '已调用', + failed: '调用失败', + cancelled: '已取消', + untitled: '未命名工具', + }, + error: { + defaultMessage: '发生错误', + retry: '重试', + }, + mcp: { + triggerLabel: '扩展', + triggerActiveTitle: '已激活 {count} 个插件', + triggerInactiveTitle: '当前没有激活插件', + addPlugin: '添加新插件', + installPlugin: '安装更多插件', + }, + sidebar: { + collapse: '折叠侧边栏', + close: '关闭侧边栏', + emptyTitle: '暂无扩展内容', + emptyDescription: '后续可在此查看文件、链接或扩展结果。', + }, +} + +export function resolveChatMessages(overrides?: ChatMessagesOverrides): ChatMessages { + if (!overrides) { + return CHAT_MESSAGES + } + + return { + header: { ...CHAT_MESSAGES.header, ...overrides.header }, + history: { ...CHAT_MESSAGES.history, ...overrides.history }, + sender: { ...CHAT_MESSAGES.sender, ...overrides.sender }, + workspace: { ...CHAT_MESSAGES.workspace, ...overrides.workspace }, + modelSelector: { ...CHAT_MESSAGES.modelSelector, ...overrides.modelSelector }, + attachments: { ...CHAT_MESSAGES.attachments, ...overrides.attachments }, + senderActions: { ...CHAT_MESSAGES.senderActions, ...overrides.senderActions }, + feedback: { ...CHAT_MESSAGES.feedback, ...overrides.feedback }, + editMessage: { ...CHAT_MESSAGES.editMessage, ...overrides.editMessage }, + toolCall: { ...CHAT_MESSAGES.toolCall, ...overrides.toolCall }, + error: { ...CHAT_MESSAGES.error, ...overrides.error }, + mcp: { ...CHAT_MESSAGES.mcp, ...overrides.mcp }, + sidebar: { ...CHAT_MESSAGES.sidebar, ...overrides.sidebar }, + } +} + +export function useResolvedChatMessages() { + return inject( + CHAT_MESSAGES_KEY, + computed(() => CHAT_MESSAGES), + ) +} diff --git a/packages/chat/src/shared/utils/iconMap.ts b/packages/chat/src/shared/utils/iconMap.ts new file mode 100644 index 000000000..6c1539d95 --- /dev/null +++ b/packages/chat/src/shared/utils/iconMap.ts @@ -0,0 +1,48 @@ +import type { Component } from 'vue' +import { + IconOpenai, + IconClaude, + IconDeepseek, + IconGemini, + IconBailian, + IconModelscope, + IconOpenrouter, + IconOllama, +} from '@opentiny/tiny-robot-svgs' +import type { ModelOption } from '@/types' + +/** + * Default provider icon registry used by chat-level model selectors. + * Custom apps can override the icon per model via `ModelOption.icon`. + */ +export const PROVIDER_ICON_MAP: Record = { + openai: IconOpenai, + claude: IconClaude, + deepseek: IconDeepseek, + gemini: IconGemini, + bailian: IconBailian, + modelscope: IconModelscope, + openrouter: IconOpenrouter, + ollama: IconOllama, +} + +export type KnownProvider = keyof typeof PROVIDER_ICON_MAP + +export const KNOWN_PROVIDERS = Object.keys(PROVIDER_ICON_MAP) as KnownProvider[] + +export function getProviderIcon(model: ModelOption | string): Component | null { + if (!model) { + return null + } + + if (typeof model === 'object' && model !== null) { + if (model.icon) { + return model.icon + } + + const providerId = model.providerId + return providerId ? (PROVIDER_ICON_MAP[providerId.toLowerCase()] ?? null) : null + } + + return PROVIDER_ICON_MAP[model.toLowerCase()] ?? null +} diff --git a/packages/chat/src/shared/utils/index.ts b/packages/chat/src/shared/utils/index.ts new file mode 100644 index 000000000..6381f1a2f --- /dev/null +++ b/packages/chat/src/shared/utils/index.ts @@ -0,0 +1,3 @@ +export * from './iconMap' +export * from './props' +export * from './typeGuards' diff --git a/packages/chat/src/shared/utils/props.ts b/packages/chat/src/shared/utils/props.ts new file mode 100644 index 000000000..8b8f33740 --- /dev/null +++ b/packages/chat/src/shared/utils/props.ts @@ -0,0 +1,8 @@ +import type { PropType } from 'vue' + +// Preserve tri-state semantics for boolean props so leaf components can +// distinguish "unset" from an explicit false when falling back to scaffold defaults. +export const triStateBooleanProp = { + type: Boolean as PropType, + default: undefined, +} as const diff --git a/packages/chat/src/shared/utils/typeGuards.ts b/packages/chat/src/shared/utils/typeGuards.ts new file mode 100644 index 000000000..0243dfdb4 --- /dev/null +++ b/packages/chat/src/shared/utils/typeGuards.ts @@ -0,0 +1,35 @@ +/** + * 类型守卫和工具函数 + * 用于处理 Union type 的属性提取和类型转换 + */ + +/** + * 从 Union type 的对象中安全地提取属性 + * 用于处理互斥的 Union type props + * + * @example + * // 对于 Union type: { a: string } | { b: number } + * const value = extractProp(props, 'a', 'string') + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function extractProp, K extends keyof T>( + obj: T, + key: K, + defaultValue?: T[K], +): T[K] | undefined { + return key in obj ? obj[key] : defaultValue +} + +/** + * 类型安全的条件属性提取 + * 用于 Union type 中的条件属性访问 + * + * @example + * // 对于 Union type: { responseProvider: Provider } | { responseProvider: Provider, storage: Storage } + * const provider = conditionalProp(props, 'responseProvider') + * const storage = conditionalProp(props, 'storage') + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function conditionalProp, K extends keyof T>(obj: T, key: K): T[K] | undefined { + return key in obj ? (obj[key] as T[K]) : undefined +} diff --git a/packages/chat/src/styles/drawer.css b/packages/chat/src/styles/drawer.css new file mode 100644 index 000000000..6312d2c73 --- /dev/null +++ b/packages/chat/src/styles/drawer.css @@ -0,0 +1,33 @@ +.tr-chat-drawer-overlay { + position: absolute; + inset: 0; + z-index: calc(var(--chat-drawer-z-index) - 1); + background: var(--chat-drawer-overlay-bg); + opacity: 0; + pointer-events: none; + transition: opacity var(--chat-drawer-transition); +} + +.tr-chat-drawer-overlay.is-open { + opacity: 1; + pointer-events: auto; +} + +.tr-chat-drawer { + position: absolute; + top: 0; + left: 0; + display: flex; + width: var(--chat-drawer-width); + height: 100%; + flex-direction: column; + z-index: var(--chat-drawer-z-index); + background: var(--chat-drawer-bg); + box-shadow: var(--chat-drawer-shadow); + transform: translateX(-100%); + transition: transform var(--chat-drawer-transition); +} + +.tr-chat-drawer.is-open { + transform: translateX(0); +} diff --git a/packages/chat/src/styles/index.css b/packages/chat/src/styles/index.css new file mode 100644 index 000000000..a0b945e37 --- /dev/null +++ b/packages/chat/src/styles/index.css @@ -0,0 +1,11 @@ +@import './tokens.css'; +@import './layout.css'; +@import './drawer.css'; +@import './mcp-trigger.css'; +@import './model-selector.css'; + +/* Hide Sender header container when slot content is empty (e.g. Attachments with v-if). + Temporary fix until the Sender component in packages/components handles this natively. */ +.tr-sender-header:not(:has(> *)) { + display: none; +} diff --git a/packages/chat/src/styles/layout.css b/packages/chat/src/styles/layout.css new file mode 100644 index 000000000..6305e0143 --- /dev/null +++ b/packages/chat/src/styles/layout.css @@ -0,0 +1,299 @@ +.tr-chat { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + width: var(--chat-width); + height: var(--chat-height); + background: var(--chat-body-bg); + color: var(--chat-text-primary); +} + +.tr-chat[data-chat-content-layout='centered'] { + --chat-welcome-area-max-width: min(100%, var(--chat-content-max-width, 1000px)); + --chat-bubble-list-max-width: min(100%, var(--chat-content-max-width, 1000px)); + --chat-footer-inner-max-width: min(100%, var(--chat-content-max-width, 1000px)); + --chat-welcome-prompts-max-width: min(100%, var(--chat-content-max-width, 1000px)); +} + +.tr-chat[data-chat-content-layout='wide'] { + --chat-welcome-area-max-width: 100%; + --chat-bubble-list-max-width: 100%; + --chat-footer-inner-max-width: 100%; + --chat-welcome-prompts-max-width: 100%; +} + +.tr-chat__header { + flex-shrink: 0; + background: var(--chat-header-bg); +} + +.tr-chat__header-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--chat-header-height); + width: 100%; + padding: var(--chat-header-padding); + box-sizing: border-box; +} + +.tr-chat__header-left { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.tr-chat__header-title { + display: flex; + align-items: center; + flex: 1; + min-width: 0; +} + +.tr-chat__header-right { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.tr-chat .tr-chat__header-brand { + margin: 0; + padding: 0; + overflow: hidden; + color: var(--chat-header-title-color, var(--tr-text-primary, #191919)); + font-size: var(--chat-header-title-font-size, 14px); + font-weight: var(--chat-header-title-font-weight, 600); + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 900px) { + .tr-chat__header-inner { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + } + + .tr-chat__header-left { + justify-self: start; + } + + .tr-chat__header-title { + justify-self: center; + justify-content: center; + max-width: min(60vw, 320px); + } + + .tr-chat__header-right { + justify-self: end; + } +} + +.tr-chat__welcome-area { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + overflow: hidden; + width: 100%; + max-width: var(--chat-welcome-area-max-width, 100%); + margin-left: auto; + margin-right: auto; + background: var(--chat-body-bg); + transition: max-width 0.28s ease; +} + +.tr-chat__body { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + overflow: hidden; + padding: var(--chat-body-padding); + background: var(--chat-body-bg); + color: var(--chat-text-primary); + transition: padding 0.24s ease; +} + +.tr-chat__body > .tr-chat__bubble-list { + flex: 1; + min-height: 0; +} + +.tr-chat__body--docs { + padding: 0; + background: var(--chat-body-overlay-docs), var(--chat-body-bg); +} + +.tr-chat__body--docs .tr-chat__bubble-list { + --gap: 16px; + --padding: 24px 32px 40px; +} + +.tr-chat__body--docs .tr-bubble[data-role='assistant'] { + width: 100%; +} + +.tr-chat__body--docs .tr-bubble[data-role='assistant'] .tr-bubble__content { + align-items: stretch; +} + +.tr-chat__body--docs .tr-bubble[data-role='assistant'] .tr-bubble__box { + width: 100%; + min-width: 0; + max-width: 100%; + background: transparent; + border: none; + box-shadow: none; + padding: 0; +} + +.tr-chat__body--docs .tr-bubble[data-role='assistant'] .tr-bubble__after { + margin-top: 10px; +} + +.tr-chat__body--docs .tr-bubble[data-role='assistant'] .tr-bubble__box .markstream-vue { + font-size: 15px; + line-height: 1.75; +} + +.tr-chat__body--docs .tr-bubble[data-role='user'] { + margin-top: 4px; +} + +.tr-chat__body--docs .tr-bubble[data-role='user'] .tr-bubble__box { + max-width: min(60%, 520px); +} + +.tr-chat__body--workspace { + padding: 0; + background: var(--chat-body-overlay-workspace), var(--chat-body-bg); +} + +.tr-chat__body--workspace .tr-chat__bubble-list { + --gap: 14px; + --padding: 24px 24px 32px; +} + +.tr-chat__bubble-list { + width: 100%; + max-width: var(--chat-bubble-list-max-width, 100%); + margin-left: auto; + margin-right: auto; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: var(--chat-bubble-list-scrollbar-thumb) transparent; + transition: max-width 0.28s ease; +} + +.tr-chat__bubble-list::-webkit-scrollbar { + width: 8px; +} + +.tr-chat__bubble-list::-webkit-scrollbar-track { + background: transparent; +} + +.tr-chat__bubble-list::-webkit-scrollbar-thumb { + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; + background-color: var(--chat-bubble-list-scrollbar-thumb); + transition: background-color 0.2s ease; +} + +.tr-chat__bubble-list::-webkit-scrollbar-thumb:hover { + background-color: var(--chat-bubble-list-scrollbar-thumb-hover); +} + +.tr-chat__body--workspace .tr-bubble[data-role='assistant'] .tr-bubble__box { + max-width: min(78%, 840px); +} + +.tr-chat__body--workspace .tr-bubble[data-role='user'] .tr-bubble__box { + max-width: min(62%, 560px); +} + +.tr-chat__welcome { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + align-items: center; + justify-content: center; + overflow-y: auto; + padding: var(--chat-welcome-padding, 24px); + container-type: inline-size; + width: 100%; +} + +.tr-chat__welcome-prompts { + width: 100%; + max-width: var(--chat-welcome-prompts-max-width, 800px); + padding: var(--chat-welcome-prompts-padding, 16px 24px); + --tr-prompt-width: 100%; +} + +@container (min-width: 640px) { + .tr-chat__welcome-prompts { + --tr-prompt-width: calc(50% - 8px); + } +} + +.tr-chat__footer { + flex-shrink: 0; +} + +.tr-chat .tr-bubble__box[data-optimistic='true'] { + opacity: 0.78; + transition: opacity 0.18s ease; +} + +.tr-chat .tr-bubble__box[data-editing='true'] { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + overflow: visible; +} + +.tr-chat .tr-bubble__content:has(.tr-bubble__box[data-editing='true']) { + gap: 0; +} + +.tr-chat .tr-bubble:has(.tr-bubble__box[data-editing='true']) .tr-bubble__body { + width: 100%; +} + +.tr-chat .tr-bubble__box[data-editing='true'] { + width: min(100%, var(--chat-edit-max-width)) !important; + max-width: min(100%, var(--chat-edit-max-width)) !important; + min-width: min(100%, var(--chat-edit-min-width)) !important; +} + +.tr-chat .tr-bubble__box[data-editing='true'] .edit-input-container { + width: 100%; + min-width: 0; +} + +.tr-chat__footer-inner { + width: 100%; + max-width: var(--chat-footer-inner-max-width, 100%); + padding: var(--chat-footer-padding); + box-sizing: border-box; + margin-left: auto; + margin-right: auto; + transition: + padding 0.24s ease, + max-width 0.28s ease; +} + +.tr-chat__footer-extra { + margin-bottom: 8px; +} + +.tr-chat-attachments-area { + padding: 8px 0 4px; +} diff --git a/packages/chat/src/styles/mcp-trigger.css b/packages/chat/src/styles/mcp-trigger.css new file mode 100644 index 000000000..dedbb49f9 --- /dev/null +++ b/packages/chat/src/styles/mcp-trigger.css @@ -0,0 +1,86 @@ +.tr-mcp-trigger__wrapper { + position: relative; + display: inline-flex; +} + +.tr-mcp-trigger__button { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 4px; + min-height: 30px; + padding: 4px 12px; + border: 1px solid var(--mcp-trigger-border-color) !important; + border-radius: 999px; + background-color: var(--mcp-trigger-bg); + color: var(--mcp-trigger-text-primary); + cursor: pointer; + font-size: 12px; + font-weight: 500; + line-height: 20px; + white-space: nowrap; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease; +} + +.tr-mcp-trigger__button:hover { + border-color: var(--mcp-trigger-border-color-hover); + background-color: var(--mcp-trigger-bg-light); +} + +.tr-mcp-trigger__button:focus-visible { + outline: none; + border-color: var(--mcp-trigger-border-blue) !important; + box-shadow: 0 0 0 3px var(--mcp-trigger-shadow-blue); +} + +.tr-mcp-trigger__button.is-active { + border-color: var(--mcp-trigger-border-blue) !important; + background-color: var(--mcp-trigger-bg-blue-light); + color: var(--mcp-trigger-text-blue); +} + +.tr-mcp-trigger__button.is-active:hover { + border-color: var(--mcp-trigger-border-blue) !important; + background-color: var(--mcp-trigger-bg-blue-light); + color: var(--mcp-trigger-text-blue); +} + +.tr-mcp-trigger__icon { + flex-shrink: 0; + font-size: 16px; +} + +.tr-mcp-trigger__label { + white-space: nowrap; +} + +.tr-mcp-trigger__count { + display: inline-flex; + min-width: 16px; + height: 16px; + align-items: center; + justify-content: center; + padding: 0 4px; + border-radius: 999px; + background: var(--mcp-trigger-count-bg); + color: var(--mcp-trigger-count-text); + font-size: 10px; + font-weight: 600; + line-height: 1; + box-sizing: border-box; +} + +@media (max-width: 768px) { + .tr-mcp-trigger__button { + min-height: 28px; + padding: 4px 8px; + } + + .tr-mcp-trigger__label { + display: none; + } +} diff --git a/packages/chat/src/styles/model-selector.css b/packages/chat/src/styles/model-selector.css new file mode 100644 index 000000000..3d4b5c8e7 --- /dev/null +++ b/packages/chat/src/styles/model-selector.css @@ -0,0 +1,173 @@ +.tr-model-selector__wrapper { + position: relative; + display: inline-block; +} + +.tr-model-selector__trigger { + display: flex; + width: fit-content; + min-height: 30px; + align-items: center; + gap: 8px; + padding: 6px 12px; + border: 1px solid var(--model-selector-border-color) !important; + border-radius: 26px; + background-color: var(--model-selector-bg); + color: var(--model-selector-text-primary); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + white-space: nowrap; +} + +.tr-model-selector__trigger:hover { + border-color: var(--model-selector-border-color-hover); + background-color: var(--model-selector-bg-light); +} + +.tr-model-selector__trigger:focus { + outline: none; + border-color: var(--model-selector-border-blue) !important; + box-shadow: 0 0 0 3px var(--model-selector-shadow-blue); +} + +.tr-model-selector__icon-provider { + font-size: 16px; + flex-shrink: 0; +} + +.tr-model-selector__chevron { + flex-shrink: 0; + margin-left: auto; + color: var(--model-selector-text-gray); + transition: transform 0.2s ease; +} + +.tr-model-selector__chevron.is-open { + transform: rotate(180deg); +} + +.tr-model-selector__dropdown-wrapper { + position: absolute; + top: 0; + left: 0; + width: max-content; + z-index: 9999; +} + +.tr-model-selector__dropdown { + min-width: 200px; + overflow: hidden; + z-index: 50; + border: 1px solid var(--model-selector-border-color); + border-radius: 12px; + background-color: var(--model-selector-bg); + box-shadow: var(--model-selector-shadow-dropdown); +} + +.tr-model-selector__content { + max-height: 320px; + overflow-y: auto; + padding: 6px; +} + +.tr-model-selector__content::-webkit-scrollbar { + width: 6px; +} + +.tr-model-selector__content::-webkit-scrollbar-track { + background: transparent; +} + +.tr-model-selector__content::-webkit-scrollbar-thumb { + border-radius: 3px; + background: var(--model-selector-scrollbar-color); +} + +.tr-model-selector__content::-webkit-scrollbar-thumb:hover { + background: var(--model-selector-scrollbar-color-hover); +} + +.tr-model-selector__item { + padding: 0; +} + +.tr-model-selector__option { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border: none; + border-radius: 6px; + background: none; + color: var(--model-selector-text-secondary); + cursor: pointer; + font-size: 14px; + text-align: left; + transition: all 0.15s ease; +} + +.tr-model-selector__option:hover { + background-color: var(--model-selector-bg-gray); +} + +.tr-model-selector__option.is-highlighted { + background-color: var(--model-selector-bg-gray-dark); + color: var(--model-selector-text-primary); +} + +.tr-model-selector__option.is-selected { + background-color: var(--model-selector-bg-blue-light); + color: var(--model-selector-text-blue); + font-weight: 500; +} + +.tr-model-selector__option.is-selected.is-highlighted { + background-color: var(--model-selector-bg-blue-lighter); + color: var(--model-selector-text-blue); +} + +.tr-model-selector__option.is-disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +.tr-model-selector__option-left { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 10px; +} + +.tr-model-selector__option-icon { + font-size: 16px; + flex-shrink: 0; +} + +.tr-model-selector__option-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tr-model-selector__check { + flex-shrink: 0; + margin-left: 8px; + color: var(--model-selector-border-blue); +} + +.tr-model-selector-fade-enter-active, +.tr-model-selector-fade-leave-active { + transition: all 0.15s ease; +} + +.tr-model-selector-fade-enter-from, +.tr-model-selector-fade-leave-to { + opacity: 0; + transform: translateY(4px); +} diff --git a/packages/chat/src/styles/tokens.css b/packages/chat/src/styles/tokens.css new file mode 100644 index 000000000..f27a9c7dc --- /dev/null +++ b/packages/chat/src/styles/tokens.css @@ -0,0 +1,271 @@ +/* + * Chat kit CSS variables. + * Users can override these at runtime without relying on Less preprocessing. + */ +:root { + --chat-width: 100%; + --chat-height: 100%; + --chat-border-radius: 16px; + + --chat-header-height: 48px; + --chat-header-padding: 0 12px; + --chat-header-bg: var(--tr-container-bg-default, #fff); + + --chat-welcome-padding: 24px; + + --chat-body-padding: 0; + --chat-body-bg: var(--tr-container-bg-default, #fff); + + --chat-footer-padding: 8px 12px; + --chat-footer-bg: var(--tr-container-bg-default, #fff); + + --chat-drawer-width: 280px; + --chat-drawer-bg: var(--tr-container-bg-default, #fff); + --chat-drawer-shadow: 4px 0 20px rgba(0, 0, 0, 0.08); + --chat-drawer-z-index: 100; + --chat-drawer-transition: 0.25s ease; + --chat-drawer-overlay-bg: rgba(0, 0, 0, 0.3); + + --chat-header-title-font-size: 14px; + --chat-header-title-font-weight: 600; + --chat-header-title-color: var(--tr-text-primary, #191919); + + --chat-welcome-prompts-max-width: min(100%, var(--chat-content-max-width)); + --chat-welcome-prompts-padding: 16px 24px; + + --chat-content-max-width: 1000px; + --chat-welcome-area-max-width: min(100%, var(--chat-content-max-width)); + --chat-bubble-list-max-width: min(100%, var(--chat-content-max-width)); + --chat-footer-inner-max-width: min(100%, var(--chat-content-max-width)); +} + +:where(.tr-chat, .tr-workspace-shell, .tr-chat-drawer, .tr-chat-workspace-right-sheet) { + --chat-surface-bg: var(--tr-container-bg-default); + --chat-surface-bg-muted: var(--tr-container-bg-default-2); + --chat-surface-bg-hover: var(--tr-container-bg-hover); + --chat-surface-bg-active: var(--tr-container-bg-active); + --chat-surface-border: var(--tr-border-color-default); + --chat-surface-border-subtle: var(--tr-border-color-disabled); + --chat-text-primary: var(--tr-text-primary); + --chat-text-secondary: var(--tr-text-secondary); + --chat-text-tertiary: var(--tr-text-tertiary); + --chat-accent-color: var(--tr-color-primary); + --chat-accent-bg: var(--tr-color-primary-light); + --chat-accent-border: var(--tr-color-primary); + --chat-shadow-sm: var(--tr-shadow-sm); + --chat-shadow-md: var(--tr-shadow-md, var(--tr-shadow-sm)); + --chat-icon-muted: var(--chat-text-tertiary); + --chat-danger-soft-bg: color-mix(in srgb, var(--tr-color-error-light, #fce3e1) 72%, transparent); + --chat-danger-text: var(--tr-color-error, #f23030); + --chat-history-panel-shadow: var(--chat-shadow-sm); + --chat-mcp-overlay-bg: var(--chat-drawer-overlay-bg); + --chat-bubble-list-scrollbar-thumb: color-mix(in srgb, var(--chat-text-primary) 18%, transparent); + --chat-bubble-list-scrollbar-thumb-hover: color-mix(in srgb, var(--chat-text-primary) 24%, transparent); + --chat-error-retry-bg: var(--chat-surface-bg); + --chat-error-retry-border: var(--chat-accent-border); + --chat-error-retry-text: var(--chat-accent-color); + --chat-body-overlay-docs: linear-gradient(180deg, rgba(247, 249, 252, 0.96) 0%, rgba(255, 255, 255, 1) 180px); + --chat-body-overlay-workspace: linear-gradient( + 180deg, + rgba(248, 250, 252, 0.96) 0%, + rgba(255, 255, 255, 1) 220px + ); + --chat-footer-overlay-bg: linear-gradient(180deg, rgba(249, 250, 251, 0) 0%, rgba(249, 250, 251, 0.9) 36%); + --chat-markdown-text-color: var(--chat-text-primary); + --chat-markdown-muted-color: var(--chat-text-secondary); + --chat-markdown-border-color: var(--chat-panel-border); + --chat-markdown-surface-muted: var(--chat-panel-bg-muted); + --chat-history-surface-border: var(--chat-panel-border); + --chat-history-surface-bg: var(--chat-drawer-bg, var(--chat-panel-bg)); + --chat-history-surface-shadow: var(--chat-panel-shadow); + --chat-history-surface-radius: 18px; + --tr-bubble-box-bg: color-mix(in srgb, var(--chat-surface-bg-muted) 68%, white 32%); + --tr-bubble-box-border: 1px solid color-mix(in srgb, var(--chat-surface-border-subtle) 88%, white 12%); + --tr-bubble-text-color: var(--chat-text-primary); + --chat-edit-surface-bg: var(--chat-surface-bg); + --chat-edit-surface-border: var(--chat-accent-border); + --chat-edit-surface-shadow: + 0 0 0 1px color-mix(in srgb, var(--chat-accent-border) 36%, transparent), + var(--chat-shadow-sm); + --chat-edit-max-width: 680px; + --chat-edit-min-width: 320px; + --chat-edit-text: var(--chat-text-primary); + --chat-edit-placeholder: var(--chat-text-tertiary); + --chat-edit-cancel-bg: transparent; + --chat-edit-cancel-border: var(--chat-surface-border-subtle); + --chat-edit-cancel-text: var(--chat-text-secondary); + --chat-edit-cancel-bg-hover: var(--chat-surface-bg-hover); + --chat-edit-save-bg: var(--chat-accent-color); + --chat-edit-save-border: var(--chat-accent-color); + --chat-edit-save-text: #fff; + --chat-edit-save-bg-hover: color-mix(in srgb, var(--chat-accent-color) 88%, black 12%); + --chat-history-control-bg: var(--chat-panel-bg); + --chat-history-control-bg-hover: var(--chat-panel-bg-muted); + --chat-history-control-border: var(--chat-panel-border); + --chat-history-control-border-hover: var(--chat-accent-border); + --chat-history-control-shadow: var(--chat-history-surface-shadow); + --chat-history-control-text: var(--chat-text-primary); + --chat-history-control-active-bg: var(--chat-panel-active-bg); + --chat-history-control-active-border: var(--chat-panel-active-border); + --chat-history-control-active-text: var(--chat-panel-active-text); + --chat-history-control-active-shadow: inset 0 0 0 1px var(--chat-panel-active-border); + --chat-history-search-bg: var(--chat-panel-bg); + --chat-history-search-bg-focus: var(--chat-panel-bg-muted); + --chat-history-search-border: var(--chat-panel-border); + --chat-history-search-border-focus: var(--chat-accent-border); + --chat-history-search-text: var(--chat-text-primary); + --chat-history-search-placeholder: var(--chat-text-tertiary); + --chat-history-search-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); + --chat-history-search-focus-ring: color-mix(in srgb, var(--chat-accent-color) 16%, transparent); + --tr-history-group-title-color: var(--chat-text-tertiary); + --tr-history-item-color: var(--chat-text-primary); + --tr-history-item-hover-bg: var(--chat-panel-bg-muted); + --tr-history-item-selected-bg: var(--chat-panel-bg); + --tr-history-item-selected-color: var(--chat-text-primary); + --tr-history-item-action-bg-hover: var(--chat-panel-bg); + --tr-history-item-editor-border-color: var(--chat-panel-border); + --tr-history-menu-list-bg: var(--chat-panel-bg); + --tr-history-menu-list-bg-hover: var(--chat-panel-bg-muted); + --tr-history-menu-list-box-shadow: var(--chat-panel-shadow); + --tr-history-menu-item-color: var(--chat-text-primary); + --tr-history-menu-item-text-color-hover: var(--chat-accent-color); + --model-selector-bg: var(--chat-panel-bg, var(--tr-container-bg-default, #fff)); + --model-selector-border-color: var(--chat-panel-border, var(--tr-border-color-default, #c2c2c2)); + --model-selector-border-color-hover: color-mix( + in srgb, + var(--model-selector-border-color) 76%, + var(--chat-accent-border, #3b82f6) 24% + ); + --model-selector-text-primary: var(--chat-text-primary, var(--tr-text-primary, #191919)); + --model-selector-text-secondary: var(--chat-text-secondary, #5a5a5a); + --model-selector-text-blue: var(--chat-accent-color, #1e40af); + --model-selector-text-gray: var(--chat-text-tertiary, #808080); + --model-selector-border-blue: var(--chat-accent-border, #3b82f6); + --model-selector-shadow-blue: color-mix(in srgb, var(--chat-accent-color, #3b82f6) 22%, transparent); + --model-selector-shadow-dropdown: var(--chat-panel-shadow, 0 10px 25px rgba(0, 0, 0, 0.1)); + --model-selector-bg-light: var(--chat-surface-bg-hover, #f5f5f5); + --model-selector-bg-gray: var(--chat-panel-bg-muted, #efefef); + --model-selector-bg-gray-dark: color-mix( + in srgb, + var(--chat-panel-bg-muted, #e8e8e8) 84%, + var(--chat-accent-bg, #eff6ff) 16% + ); + --model-selector-bg-blue-light: var(--chat-panel-active-bg, #eff6ff); + --model-selector-bg-blue-lighter: color-mix( + in srgb, + var(--chat-panel-active-bg, #dbeafe) 82%, + var(--chat-accent-border, #3b82f6) 18% + ); + --model-selector-scrollbar-color: color-mix( + in srgb, + var(--model-selector-border-color) 78%, + transparent + ); + --model-selector-scrollbar-color-hover: color-mix( + in srgb, + var(--model-selector-border-color-hover) 88%, + transparent + ); + --mcp-trigger-bg: var(--chat-panel-bg, var(--tr-container-bg-default, #fff)); + --mcp-trigger-border-color: var(--chat-panel-border, var(--tr-border-color-default, #c2c2c2)); + --mcp-trigger-border-color-hover: color-mix( + in srgb, + var(--mcp-trigger-border-color) 76%, + var(--chat-accent-border, #3b82f6) 24% + ); + --mcp-trigger-text-primary: var(--chat-text-primary, var(--tr-text-primary, #191919)); + --mcp-trigger-text-blue: var(--chat-accent-color, #1e40af); + --mcp-trigger-border-blue: var(--chat-accent-border, #3b82f6); + --mcp-trigger-shadow-blue: color-mix(in srgb, var(--chat-accent-color, #3b82f6) 22%, transparent); + --mcp-trigger-bg-light: var(--chat-surface-bg-hover, #f5f5f5); + --mcp-trigger-bg-blue-light: var(--chat-panel-active-bg, #eff6ff); + --mcp-trigger-count-bg: var(--chat-accent-color, #1476ff); + --mcp-trigger-count-text: #fff; +} + +:where(.tr-chat, .tr-workspace-shell, .tr-chat-drawer, .tr-chat-workspace-right-sheet) { + --chat-surface-shadow: none; + --chat-surface-elevated-bg: var(--chat-surface-bg); + --chat-surface-elevated-border: var(--chat-surface-border-subtle); + --chat-drawer-bg: var(--chat-panel-bg, var(--chat-surface-bg)); + --chat-panel-bg: var(--chat-surface-bg); + --chat-panel-bg-muted: var(--chat-surface-bg-muted); + --chat-panel-border: var(--chat-surface-border-subtle); + --chat-panel-shadow: var(--chat-shadow-sm); + --chat-panel-active-bg: var(--chat-accent-bg); + --chat-panel-active-border: color-mix(in srgb, var(--chat-accent-border) 36%, transparent); + --chat-panel-active-text: var(--chat-accent-color); + --chat-header-bg: var(--chat-surface-bg); + --chat-body-bg: var(--chat-surface-bg); + --chat-footer-bg: var(--chat-surface-bg); + --chat-header-title-color: var(--chat-text-primary); + + --chat-workspace-bg: var(--chat-surface-bg); + --chat-workspace-panel-bg: var(--chat-panel-bg); + --chat-workspace-panel-bg-muted: var(--chat-panel-bg-muted); + --chat-workspace-border: var(--chat-panel-border); + --chat-workspace-text-primary: var(--chat-text-primary); + --chat-workspace-text-secondary: var(--chat-text-secondary); + --chat-workspace-accent: var(--chat-accent-color); + --chat-workspace-accent-soft: var(--chat-accent-bg); + --chat-workspace-hover-bg: var(--chat-surface-bg-hover); + --chat-workspace-shadow: var(--chat-shadow-sm); + --chat-workspace-overlay-bg: var(--chat-drawer-overlay-bg); + --chat-workspace-rail-width: 48px; + --chat-workspace-sidebar-header-padding: 24px 22px 10px; + --chat-workspace-sidebar-title-size: 18px; + --chat-workspace-sidebar-icon-size: 28px; + --chat-workspace-sidebar-toggle-size: 32px; + --chat-workspace-sidebar-toggle-radius: 10px; + --chat-workspace-rail-padding: 20px 0; + --chat-workspace-rail-gap: 18px; + --chat-workspace-rail-button-size: 44px; + --chat-workspace-rail-button-radius: 14px; + --chat-workspace-rail-icon-size: 18px; + --chat-workspace-rail-brand-size: 28px; + --chat-workspace-right-header-padding: 0 12px; + --chat-workspace-right-title-size: var(--chat-header-title-font-size); + --chat-workspace-right-close-size: 28px; + --chat-workspace-right-close-radius: 8px; + --chat-workspace-right-body-padding: 0 2px 2px; + --chat-workspace-empty-padding: 40px 28px; + --chat-workspace-empty-gap: 10px; + --chat-workspace-empty-icon-size: 72px; + --chat-workspace-empty-icon-radius: 24px; + --chat-workspace-empty-title-size: 18px; + --chat-workspace-empty-text-size: 14px; +} + +[data-tr-color-mode='dark'] { + --chat-drawer-shadow: 4px 0 20px rgba(0, 0, 0, 0.48); + --chat-drawer-overlay-bg: rgba(0, 0, 0, 0.5); +} + +[data-tr-color-mode='dark'].tr-chat, +[data-tr-color-mode='dark'].tr-workspace-shell, +[data-tr-color-mode='dark'].tr-chat-drawer, +[data-tr-color-mode='dark'].tr-chat-workspace-right-sheet, +[data-tr-color-mode='dark'] .tr-chat, +[data-tr-color-mode='dark'] .tr-workspace-shell, +[data-tr-color-mode='dark'] .tr-chat-drawer, +[data-tr-color-mode='dark'] .tr-chat-workspace-right-sheet { + --chat-surface-bg: #262626; + --chat-surface-bg-muted: rgba(255, 255, 255, 0.05); + --chat-surface-bg-hover: rgba(255, 255, 255, 0.1); + --chat-surface-bg-active: rgba(255, 255, 255, 0.15); + --chat-surface-border: rgba(255, 255, 255, 0.16); + --chat-surface-border-subtle: rgba(255, 255, 255, 0.12); + --chat-text-primary: #e6e6e6; + --chat-text-secondary: #b3b3b3; + --chat-text-tertiary: #808080; + --chat-accent-color: #5291ff; + --chat-accent-bg: rgba(82, 145, 255, 0.2); + --chat-accent-border: rgba(82, 145, 255, 0.56); + --chat-shadow-sm: 0 12px 30px rgba(0, 0, 0, 0.28); + --chat-shadow-md: 0 16px 36px rgba(0, 0, 0, 0.34); + --chat-body-overlay-docs: linear-gradient(180deg, rgba(38, 38, 38, 0.28) 0%, rgba(25, 25, 25, 0) 180px); + --chat-body-overlay-workspace: linear-gradient(180deg, rgba(38, 38, 38, 0.32) 0%, rgba(25, 25, 25, 0) 220px); + --chat-footer-overlay-bg: linear-gradient(180deg, rgba(38, 38, 38, 0) 0%, rgba(38, 38, 38, 0.92) 36%); + --chat-body-bg: var(--tr-page-bg-default); + --chat-header-title-color: var(--chat-text-primary); +} diff --git a/packages/chat/src/types/component.ts b/packages/chat/src/types/component.ts new file mode 100644 index 000000000..bfc055a70 --- /dev/null +++ b/packages/chat/src/types/component.ts @@ -0,0 +1,345 @@ +import type { Component, Ref, VNode } from 'vue' +import type { + Attachment, + AttachmentListProps, + BubbleBoxRendererMatch, + BubbleContentRendererMatch, + BubbleListProps, + BubbleListSlots, + PromptProps, + SenderProps, + UploadButtonProps, + VoiceButtonProps, +} from '@opentiny/tiny-robot' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import type { UseMcpManagerReturn } from '../components/mcp/useMcpManager' +import type { UseChatAttachmentsReturn } from '../components/attachments/useChatAttachments' +import type { + BrandConfig, + ChatAppearanceConfig, + ChatContentLayout, + ChatErrorInfo, + ChatListVariant, + ChatMessageActionsInput, + ChatMessageActionsMode, + ChatMessageActionPayload, + ChatStatus, + TrChatProviderRuntimeOptions, +} from './core' +import type { ModelOption } from './model' +import type { TrChatConfigEntryInput } from './config' +import type { ChatWorkspaceShellConfig } from './workspace' + +type ReadonlyRef = Readonly> + +export interface WelcomeConfig { + title: string + description?: string + icon?: VNode | Component + prompts?: PromptProps[] +} + +export interface ChatMessages { + header: { + newChat: string + openHistory: string + closeHistory: string + close: string + } + history: { + newSession: string + manage: string + done: string + defaultConversationTitle: string + searchPlaceholder: string + deleteSelected: string + cancel: string + } + sender: { + placeholder: string + } + workspace: { + expandLeftSidebar: string + expandRightSidebar: string + historyRailLabel: string + previewRailLabel: string + toggleRightPanel: string + rightPanelTitle: string + closeRightPanel: string + } + modelSelector: { + triggerLabel: string + } + attachments: { + uploadTooltip: string + } + senderActions: { + uploadTooltip: string + voiceTooltip: string + } + feedback: { + copy: string + edit: string + regenerate: string + } + editMessage: { + placeholder: string + cancel: string + save: string + saving: string + } + toolCall: { + running: string + success: string + failed: string + cancelled: string + untitled: string + } + error: { + defaultMessage: string + retry: string + } + mcp: { + triggerLabel: string + triggerActiveTitle: string + triggerInactiveTitle: string + addPlugin: string + installPlugin: string + } + sidebar: { + collapse: string + close: string + emptyTitle: string + emptyDescription: string + } +} + +export type ChatMessagesOverrides = { + [K in keyof ChatMessages]?: Partial +} + +export interface ChatAttachmentsUploadConfig extends Pick< + UploadButtonProps, + 'accept' | 'multiple' | 'maxCount' | 'maxSize' | 'tooltip' | 'tooltipPlacement' +> { + enabled?: boolean +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ChatAttachmentsListConfig extends Pick< + AttachmentListProps, + 'variant' | 'wrap' | 'actions' | 'fileIcons' | 'fileMatchers' | 'disabled' +> {} + +export interface ChatAttachmentsFeaturePreset { + enabled?: boolean + upload?: ChatAttachmentsUploadConfig + list?: ChatAttachmentsListConfig +} + +export interface ChatSenderActionUploadConfig extends Pick< + UploadButtonProps, + 'accept' | 'multiple' | 'maxCount' | 'maxSize' | 'tooltip' | 'tooltipPlacement' +> { + enabled?: boolean +} + +export interface ChatSenderActionVoiceConfig extends Pick< + VoiceButtonProps, + 'tooltip' | 'tooltipPlacement' | 'size' | 'speechConfig' | 'autoInsert' | 'onButtonClick' +> { + enabled?: boolean + icon?: VoiceButtonProps['icon'] + recordingIcon?: VoiceButtonProps['recordingIcon'] +} + +export interface ChatSenderActionsFeaturePreset { + enabled?: boolean + upload?: ChatSenderActionUploadConfig + voice?: ChatSenderActionVoiceConfig + wordCount?: boolean + defaultActions?: SenderProps['defaultActions'] +} + +export interface UseChatAttachmentsOptions { + initialItems?: Attachment[] +} + +export interface ChatBubbleRenderers { + contentMatches?: BubbleContentRendererMatch[] + boxMatches?: BubbleBoxRendererMatch[] +} + +export interface TrChatPresetOverrides { + mcpManager?: UseMcpManagerReturn + attachmentsManager?: UseChatAttachmentsReturn + appearance?: ChatAppearanceConfig + shell?: ChatWorkspaceShellConfig + brand?: BrandConfig + welcome?: WelcomeConfig + prompts?: PromptProps[] + attachmentsFeature?: ChatAttachmentsFeaturePreset + senderActionsFeature?: ChatSenderActionsFeaturePreset + messages?: ChatMessagesOverrides + placeholder?: string + maxLength?: number + senderMode?: 'single' | 'multiple' + autoScroll?: boolean + messageListVariant?: ChatListVariant + contentLayout?: ChatContentLayout + bubbleRenderers?: ChatBubbleRenderers + showHistory?: boolean + showFeedback?: boolean + show?: boolean + messageActions?: ChatMessageActionsInput + messageActionsMode?: ChatMessageActionsMode + onMessageAction?: (payload: ChatMessageActionPayload) => void + roleConfigs?: BubbleListProps['roleConfigs'] + groupStrategy?: BubbleListProps['groupStrategy'] + senderProps?: SenderProps + bubbleListProps?: Omit + historyProps?: Record + onModelChange?: (model: ModelOption) => void +} + +export interface TrChatProps { + config: TrChatConfigEntryInput + mcpManager?: UseMcpManagerReturn +} + +export type TrChatProviderSharedProps = { + mcpManager?: UseMcpManagerReturn + attachmentsManager?: UseChatAttachmentsReturn + attachmentsFeature?: ChatAttachmentsFeaturePreset + senderActionsFeature?: ChatSenderActionsFeaturePreset + messages?: ChatMessagesOverrides + shell?: ChatWorkspaceShellConfig +} + +export type TrChatProviderProps = TrChatProviderSharedProps & TrChatProviderRuntimeOptions + +export interface TrChatHeaderProps { + showHistory?: boolean + showNewChat?: boolean + showClose?: boolean + title?: string + shell?: ChatWorkspaceShellConfig +} + +export interface TrChatHeaderEmits { + (e: 'close'): void +} + +export interface TrChatHeaderSlots { + title?: () => unknown + extra?: () => unknown +} + +export interface TrChatWelcomeProps { + title?: string + description?: string + icon?: VNode | Component + prompts?: PromptProps[] +} + +export interface TrChatWelcomeEmits { + (e: 'prompt-click', description: string): void +} + +export interface TrChatHistoryProps { + enabled?: boolean + appearance?: ChatAppearanceConfig +} + +export interface TrChatMessageListProps { + autoScroll?: boolean + variant?: ChatListVariant + messageActions?: ChatMessageActionsInput + messageActionsMode?: ChatMessageActionsMode + onActionClick?: (payload: ChatMessageActionPayload) => void + groupStrategy?: BubbleListProps['groupStrategy'] + roleConfigs?: BubbleListProps['roleConfigs'] + bubbleListProps?: Partial +} + +export interface TrChatSenderProps { + mode?: 'single' | 'multiple' + placeholder?: string + maxLength?: number + extensions?: SenderProps['extensions'] + senderProps?: Partial +} + +export interface TrChatSenderFooterRightSlotProps { + [key: string]: unknown +} + +export interface TrChatSenderSlots { + 'footer-right'?: (props?: TrChatSenderFooterRightSlotProps) => unknown + footer?: (props?: TrChatSenderFooterRightSlotProps) => unknown + [name: string]: ((props?: TrChatSenderFooterRightSlotProps) => unknown) | undefined +} + +export type TrChatSenderForwardedProps = Omit< + SenderProps, + | 'modelValue' + | 'defaultValue' + | 'loading' + | 'mode' + | 'placeholder' + | 'maxLength' + | 'extensions' + | 'defaultActions' + | 'showWordLimit' +> + +export interface TrChatPageProps { + messageListVariant?: ChatListVariant +} + +export interface TrChatPageEmits { + (e: 'update:show', value: boolean): void + (e: 'update:model', value: string): void +} + +export interface TrChatPageMessageListSlotProps { + messages: ReadonlyRef +} + +export interface TrChatPageSenderSlotProps { + send: (content: string) => void + abort: () => Promise + status: ReadonlyRef + lastError: ReadonlyRef + retry: () => Promise +} + +export interface TrChatPageBubbleSlotProps { + [key: string]: unknown +} + +export interface TrChatPageSlots { + left?: () => unknown + 'left-rail'?: () => unknown + right?: () => unknown + 'mobile-left'?: () => unknown + 'mobile-right'?: () => unknown + header?: () => unknown + 'header-extra'?: () => unknown + 'message-list'?: (props: TrChatPageMessageListSlotProps) => unknown + welcome?: () => unknown + empty?: () => unknown + prefix?: (props: TrChatPageBubbleSlotProps) => unknown + suffix?: (props: TrChatPageBubbleSlotProps) => unknown + after?: (props: TrChatPageBubbleSlotProps) => unknown + 'content-footer'?: (props: TrChatPageBubbleSlotProps) => unknown + sender?: (props: TrChatPageSenderSlotProps) => unknown + 'footer-extra'?: () => unknown +} + +export type TrChatMessageListForwardedProps = Omit< + BubbleListProps, + 'messages' | 'autoScroll' | 'groupStrategy' | 'roleConfigs' +> + +export type TrChatMessageListSlots = BubbleListSlots diff --git a/packages/chat/src/types/config.ts b/packages/chat/src/types/config.ts new file mode 100644 index 000000000..b8f8dead0 --- /dev/null +++ b/packages/chat/src/types/config.ts @@ -0,0 +1,130 @@ +import type { ChatMessage, ConversationStorageStrategy } from '@opentiny/tiny-robot-kit' +import type { + BrandConfig, + ChatAppearanceConfig, + ChatContentLayout, + ChatMessageActionDefinition, + ChatMessageActionsMode, + ChatMessageTransforms, +} from './core' +import type { ModelOption } from './model' +import type { + ChatAttachmentsListConfig, + ChatAttachmentsUploadConfig, + ChatBubbleRenderers, + ChatMessagesOverrides, + ChatSenderActionVoiceConfig, + WelcomeConfig, +} from './component' +import type { ChatShellVariant, ChatWorkspaceRegionConfig } from './workspace' +import type { ChatBeforeSendHandler, ChatAfterReceiveHandler, ChatErrorHandler } from './message' +import type { ChatRuntimeInput } from './runtime' +import type { UseMcpManagerReturn } from '../components/mcp/useMcpManager' + +export interface TrChatRootUiConfig { + brand?: BrandConfig + welcome?: WelcomeConfig + appearance?: ChatAppearanceConfig + contentLayout?: ChatContentLayout + labels?: ChatMessagesOverrides +} + +export interface TrChatRootProps { + runtime: ChatRuntimeInput + ui?: TrChatRootUiConfig + mcpManager?: UseMcpManagerReturn +} + +export interface TrChatRequestModel { + id: string + providerId: string + label?: string + icon?: ModelOption['icon'] + disabled?: boolean +} + +export interface TrChatTransportConfig { + type?: 'openai-compatible' + endpoint?: string + baseURL?: string + apiPath?: string + systemPrompt?: string + temperature?: number + maxTokens?: number + headers?: Record + credentials?: RequestCredentials +} + +export interface TrChatRequestConfig { + providers: Record + models: TrChatRequestModel[] + defaultModelId?: string | null + systemPrompt?: string +} + +export interface TrChatConversationConfig { + initialMessages?: ChatMessage[] + persistence?: ConversationStorageStrategy +} + +export type TrChatUiConfig = TrChatRootUiConfig + +export interface TrChatSenderConfig { + placeholder?: string + mode?: 'single' | 'multiple' + maxLength?: number + wordCount?: boolean + voice?: ChatSenderActionVoiceConfig +} + +export interface TrChatAttachmentsConfig { + enabled?: boolean + upload?: ChatAttachmentsUploadConfig + list?: ChatAttachmentsListConfig +} + +export interface TrChatHistoryConfig { + enabled?: boolean + defaultOpen?: boolean +} + +export interface TrChatWorkspaceConfig { + enabled?: boolean + left?: ChatWorkspaceRegionConfig + right?: ChatWorkspaceRegionConfig + defaultView?: ChatShellVariant +} + +export interface TrChatMessagesConfig { + actions?: ChatMessageActionDefinition[] + actionMode?: ChatMessageActionsMode + renderers?: ChatBubbleRenderers + feedback?: { enabled?: boolean } + transforms?: ChatMessageTransforms +} + +export interface TrChatLifecycleConfig { + beforeSend?: ChatBeforeSendHandler + afterReceive?: ChatAfterReceiveHandler + error?: ChatErrorHandler +} + +export interface TrChatConfig { + request: TrChatRequestConfig + conversation?: TrChatConversationConfig + ui?: TrChatUiConfig + workspace?: TrChatWorkspaceConfig + sender?: TrChatSenderConfig + attachments?: TrChatAttachmentsConfig + history?: TrChatHistoryConfig + messages?: TrChatMessagesConfig + lifecycle?: TrChatLifecycleConfig +} + +export type TrChatConfigEntryInput = TrChatConfig | string + +export interface CreateRuntimeFromConfigResult { + runtime: ChatRuntimeInput + ui: TrChatRootUiConfig + dispose: () => void +} diff --git a/packages/chat/src/types/core.ts b/packages/chat/src/types/core.ts new file mode 100644 index 000000000..d14b428a1 --- /dev/null +++ b/packages/chat/src/types/core.ts @@ -0,0 +1,175 @@ +import type { Component, ComputedRef, VNode } from 'vue' +import type { + BasePluginContext, + ChatCompletion, + ChatMessage, + CompletionChoice, + ConversationStorageStrategy, + MessageRequestBody, + RequestProcessingState, + RequestState, + UseConversationReturn, + UseMessageReturn, + UseMessageOptions, + UseMessagePlugin, +} from '@opentiny/tiny-robot-kit' +import type { FeedbackProps } from '@opentiny/tiny-robot' +import type { ChatRuntime } from './runtime' + +export type ResponseProvider = ( + requestBody: MessageRequestBody, + abortSignal: AbortSignal, +) => Promise | AsyncGenerator | Promise> + +export type ChatTransportAdapter = ResponseProvider + +export type UseMessageResponseProvider = UseMessageOptions['responseProvider'] + +export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error' + +export type ChatErrorType = 'network' | 'auth' | 'rate_limit' | 'timeout' | 'server' | 'provider' | 'unknown' + +export interface ChatErrorInfo { + type: ChatErrorType + message: string + retryable: boolean + httpStatus?: number + statusCode?: number + code?: string + providerId?: string + originalError?: unknown +} + +export interface ChatMessageActionPayload { + action: string + placement?: ChatMessageActionPlacement + role?: string + messages: ChatMessage[] + messageIds: string[] + messageIndexes: number[] + message?: ChatMessage + messageIndex?: number + messageId?: string + conversationId?: string +} + +export type ChatMessageActionRole = 'assistant' | 'user' | 'tool' | 'system' +export type ChatMessageActionPlacement = 'actions' | 'operations' +export type ChatMessageActionsMode = 'append' | 'replace' + +export interface ChatMessageActionContext { + role?: string + messages: ChatMessage[] + messageIds: string[] + messageIndexes: number[] + message?: ChatMessage + messageIndex?: number + messageId?: string + runtime?: ChatRuntime | null + conversationId?: string +} + +export interface ChatMessageActionDefinition { + id: string + label: string + icon?: NonNullable[number]['icon'] + placement?: ChatMessageActionPlacement + roles?: ChatMessageActionRole[] + order?: number + when?: (context: ChatMessageActionContext) => boolean + onClick?: (context: ChatMessageActionContext) => void | Promise +} + +export type ChatMessageActionsInput = + | ChatMessageActionDefinition[] + | ((context: ChatMessageActionContext) => ChatMessageActionDefinition[]) + +export interface ChatMessageTransformChunkContext extends BasePluginContext { + currentMessage: ChatMessage + choice?: CompletionChoice + chunk: ChatCompletion +} + +export interface ChatMessageTransformFinishContext extends BasePluginContext { + message: ChatMessage +} + +export interface ChatMessageTransforms { + onChunk?: (context: ChatMessageTransformChunkContext) => void + onFinish?: (context: ChatMessageTransformFinishContext) => Partial | void +} + +export type ChatListVariant = 'bubble' | 'docs' | 'workspace' +export type ChatContentLayout = 'centered' | 'wide' + +export type ChatAppearanceMode = 'light' | 'dark' | 'system' + +export interface ChatAppearanceConfig { + mode?: ChatAppearanceMode +} + +export interface TrChatProviderRuntimeOptionsBase { + plugins?: UseMessagePlugin[] + storage?: ConversationStorageStrategy + initialMessages?: ChatMessage[] + messageTransforms?: ChatMessageTransforms + onFinish?: (message: ChatMessage) => void + onError?: (error: Error) => void +} + +type TrChatProviderTransportSource = + | { + responseProvider: ResponseProvider + transportAdapter?: never + } + | { + transportAdapter: ChatTransportAdapter + responseProvider?: never + } + +export type TrChatProviderRuntimeOptions = TrChatProviderTransportSource & TrChatProviderRuntimeOptionsBase + +export interface UseChatKitOptions extends TrChatProviderRuntimeOptionsBase { + responseProvider: ResponseProvider + onAfterReceive?: (message: ChatMessage) => void +} + +export interface UseChatKitRuntimeBridge { + activeEngine: ComputedRef + requestState: ComputedRef + processingState: ComputedRef + isProcessing: ComputedRef + clear: UseConversationReturn['clear'] + saveMessages: UseConversationReturn['saveMessages'] +} + +export interface UseChatKitReturn extends Pick< + UseConversationReturn, + | 'activeConversationId' + | 'activeConversation' + | 'createConversation' + | 'switchConversation' + | 'deleteConversation' + | 'updateConversationTitle' + | 'abortActiveRequest' +> { + conversations: UseConversationReturn['conversations'] + messages: ComputedRef + status: ComputedRef + lastError: ComputedRef + sendMessage: (content: string, options?: { attachments?: unknown[] }) => void + startEditMessage: (messageIndex: number) => void + cancelEditMessage: (messageIndex: number) => void + isMessageEditing: (messageIndex: number) => boolean + editMessage: (messageIndex: number, newContent: string) => void + updateResponseProvider: (provider: ResponseProvider) => void + abort: () => Promise + retry: () => Promise + regenerate: (messageIndex?: number) => Promise + runtime: UseChatKitRuntimeBridge +} + +export interface BrandConfig { + title?: string + logo?: VNode | Component +} diff --git a/packages/chat/src/types/index.ts b/packages/chat/src/types/index.ts new file mode 100644 index 000000000..ae1863148 --- /dev/null +++ b/packages/chat/src/types/index.ts @@ -0,0 +1,124 @@ +export type { + BrandConfig, + ChatAppearanceConfig, + ChatContentLayout, + ChatMessageActionContext, + ChatMessageActionDefinition, + ChatMessageActionPlacement, + ChatAppearanceMode, + ChatTransportAdapter, + ChatErrorInfo, + ChatErrorType, + ChatListVariant, + ChatMessageActionsInput, + ChatMessageActionsMode, + ChatMessageActionRole, + ChatMessageActionPayload, + ChatMessageTransformChunkContext, + ChatMessageTransformFinishContext, + ChatMessageTransforms, + ChatStatus, + ResponseProvider, + TrChatProviderRuntimeOptions, + UseMessageResponseProvider, +} from './core' + +export type { + ChatBubbleRenderers, + ChatAttachmentsFeaturePreset, + ChatAttachmentsListConfig, + ChatAttachmentsUploadConfig, + ChatMessages, + ChatMessagesOverrides, + ChatSenderActionsFeaturePreset, + ChatSenderActionUploadConfig, + ChatSenderActionVoiceConfig, + TrChatHeaderProps, + TrChatHeaderEmits, + TrChatHeaderSlots, + TrChatHistoryProps, + TrChatMessageListForwardedProps, + TrChatMessageListSlots, + TrChatMessageListProps, + TrChatPageBubbleSlotProps, + TrChatPageEmits, + TrChatPageMessageListSlotProps, + TrChatPageProps, + TrChatPageSenderSlotProps, + TrChatPageSlots, + TrChatPresetOverrides, + TrChatProps, + TrChatProviderSharedProps, + TrChatProviderProps, + TrChatSenderProps, + TrChatSenderFooterRightSlotProps, + TrChatSenderForwardedProps, + TrChatSenderSlots, + TrChatWelcomeProps, + TrChatWelcomeEmits, + UseChatAttachmentsOptions, + WelcomeConfig, +} from './component' + +export type { ModelOption } from './model' +export type { + ChatShellVariant, + ChatWorkspaceRegionCollapseMode, + ChatWorkspaceRegionConfig, + ChatWorkspaceRegionWidth, + ChatWorkspaceShellConfig, + ChatWorkspaceViewStateConfig, + TrChatWorkspaceShellProps, +} from './workspace' + +// message types +export type { + ReadonlyRef, + ChatUIMessageRole, + ChatUIMessagePart, + ChatUIMessageMeta, + ChatUIMessage, + ChatMessageViewState, + ChatSendInput, + ChatBeforeSendInput, + ChatBeforeSendHandler, + ChatErrorHandler, + ChatAfterReceiveHandler, + ChatConversationSummary, + ChatConversationCreateInput, +} from './message' + +// runtime types +export type { + ChatConversationRuntime, + ChatSenderRuntime, + ChatMessageRuntime, + ChatAttachmentsRuntime, + ChatHistoryRuntime, + ChatModelRuntime, + ChatWorkspaceRegionRuntime, + ChatWorkspaceRuntime, + ChatMcpRuntime, + ChatRuntimeInput, + ChatRuntime, +} from './runtime' + +// config types +export type { + TrChatRootUiConfig, + TrChatRootProps, + TrChatRequestModel, + TrChatTransportConfig, + TrChatRequestConfig, + TrChatConversationConfig, + TrChatUiConfig, + TrChatSenderConfig, + TrChatAttachmentsConfig, + TrChatHistoryConfig, + TrChatWorkspaceConfig, + TrChatMessagesConfig, + TrChatLifecycleConfig, + TrChatConfig, + TrChatConfigEntryInput, + CreateRuntimeFromConfigResult, +} from './config' diff --git a/packages/chat/src/types/message.ts b/packages/chat/src/types/message.ts new file mode 100644 index 000000000..0475cf188 --- /dev/null +++ b/packages/chat/src/types/message.ts @@ -0,0 +1,75 @@ +import type { Ref } from 'vue' +import type { Attachment } from '@opentiny/tiny-robot' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import type { ChatErrorInfo } from './core' + +export type ReadonlyRef = Readonly> + +export type ChatUIMessageRole = 'system' | 'user' | 'assistant' | 'tool' | '' + +export type ChatUIMessagePart = + | { type: 'text'; text: string } + | { type: 'attachment'; attachment: Attachment } + | { type: 'unknown'; value: unknown } + +export interface ChatUIMessageMeta { + conversationId?: string + parentMessageId?: string + turnId?: string + model?: string +} + +export interface ChatUIMessage { + id: string + role: ChatUIMessageRole + createdAt?: number + parts: ChatUIMessagePart[] + meta?: ChatUIMessageMeta + raw?: unknown +} + +export interface ChatMessageViewState { + status?: 'pending' | 'streaming' | 'done' | 'error' + error?: ChatErrorInfo + editing?: boolean + optimistic?: boolean + capabilities?: { + editable?: boolean + retryable?: boolean + regeneratable?: boolean + feedbackable?: boolean + } +} + +export interface ChatSendInput { + text: string + attachments?: Attachment[] + modelId?: string | null +} + +export interface ChatBeforeSendInput { + text: string + attachments?: Attachment[] +} + +export type ChatBeforeSendHandler = ( + input: ChatBeforeSendInput, +) => + | ChatBeforeSendInput + | Partial + | false + | void + | Promise | false | void> + +export type ChatErrorHandler = (error: ChatErrorInfo | Error) => void + +export type ChatAfterReceiveHandler = (message: ChatMessage) => void + +export interface ChatConversationSummary { + id: string + title?: string +} + +export interface ChatConversationCreateInput { + title?: string +} diff --git a/packages/chat/src/types/model.ts b/packages/chat/src/types/model.ts new file mode 100644 index 000000000..51f6e37e5 --- /dev/null +++ b/packages/chat/src/types/model.ts @@ -0,0 +1,9 @@ +import type { Component } from 'vue' + +export interface ModelOption { + value: string + label?: string + providerId?: string + icon?: Component + disabled?: boolean +} diff --git a/packages/chat/src/types/runtime.ts b/packages/chat/src/types/runtime.ts new file mode 100644 index 000000000..0b14608fc --- /dev/null +++ b/packages/chat/src/types/runtime.ts @@ -0,0 +1,154 @@ +import type { Ref } from 'vue' +import type { Attachment } from '@opentiny/tiny-robot' +import type { ChatMessageActionDefinition, ChatMessageActionsMode, ChatMessageTransforms, ChatStatus } from './core' +import type { ModelOption } from './model' +import type { + ChatAttachmentsListConfig, + ChatAttachmentsUploadConfig, + ChatBubbleRenderers, + ChatSenderActionVoiceConfig, +} from './component' +import type { ChatShellVariant, ChatWorkspaceRegionCollapseMode, ChatWorkspaceRegionWidth } from './workspace' +import type { + ReadonlyRef, + ChatUIMessage, + ChatMessageViewState, + ChatSendInput, + ChatConversationSummary, + ChatConversationCreateInput, +} from './message' + +// Re-export message types so consumers of '@/types/runtime' don't need a separate '@/types/message' import +export type { + ReadonlyRef, + ChatUIMessage, + ChatMessageViewState, + ChatSendInput, + ChatConversationSummary, + ChatConversationCreateInput, +} + +export interface ChatConversationRuntime { + messages: ReadonlyRef + status: ReadonlyRef + send: (input: ChatSendInput) => Promise | void + abort: () => Promise | void + retry: (messageId?: string) => Promise | boolean + regenerate: (messageId?: string) => Promise | boolean +} + +export interface ChatSenderRuntime { + draft: Ref + pendingAttachments: Ref + canSend: ReadonlyRef + setDraft: (value: string) => void + send: (input?: Partial) => Promise | void + addPendingAttachments: (attachments: Attachment[]) => void + setPendingAttachments: (attachments: Attachment[]) => void + removePendingAttachment: (attachment: Attachment) => void + clearPendingAttachments: () => void + defaults?: { + placeholder?: string + mode?: 'single' | 'multiple' + maxLength?: number + wordCount?: boolean + voice?: ChatSenderActionVoiceConfig + } +} + +export interface ChatMessageRuntime { + getViewState: (messageId: string) => ChatMessageViewState | undefined + getActions?: (messageId: string) => ChatMessageActionDefinition[] + startEdit: (messageId: string) => void + cancelEdit: (messageId: string) => void + commitEdit: (messageId: string, nextContent: string) => Promise | boolean + copy: (messageId: string) => Promise | void + config?: { + actions?: ChatMessageActionDefinition[] + actionMode?: ChatMessageActionsMode + renderers?: ChatBubbleRenderers + feedback?: { enabled?: boolean } + transforms?: ChatMessageTransforms + } +} + +export interface ChatAttachmentsRuntime { + enabled: ReadonlyRef + prepareFiles: (files: File[]) => Attachment[] + uploadConfig?: ReadonlyRef + listConfig?: ReadonlyRef +} + +export interface ChatHistoryRuntime { + conversations: ReadonlyRef + activeConversationId: ReadonlyRef + createConversation: (params?: ChatConversationCreateInput) => Promise | string + switchConversation: (id: string) => Promise | boolean + deleteConversation: (id: string) => Promise | boolean + renameConversation?: (id: string, title: string) => Promise | boolean +} + +export interface ChatModelRuntime { + models: ReadonlyRef + currentModelId: ReadonlyRef + selectModel: (modelId: string) => Promise
{{ error?.message || chatMessages.error.defaultMessage }}