diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index b99bb8eca..970f16df9 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -6,8 +6,6 @@ com.apple.security.cs.allow-unsigned-executable-memory - com.apple.security.cs.allow-dyld-environment-variables - com.apple.security.files.user-selected.read-write com.apple.security.files.user-selected.read-only diff --git a/docs/architecture/new-ui-implementation-plan.md b/docs/architecture/new-ui-implementation-plan.md new file mode 100644 index 000000000..a2f77ba7a --- /dev/null +++ b/docs/architecture/new-ui-implementation-plan.md @@ -0,0 +1,610 @@ +# New UI Feature Implementation Plan + +This document defines the technical plan for implementing complete functionality on the new UI architecture, without considering legacy compatibility migration, based on entirely new code implementation. + +--- + +## 1. Architecture Overview + +### 1.1 Target Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ChatTabView (Entry) │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ WelcomePage │ Displayed when no Provider config ││ +│ ├─────────────────────────────────────────────────────────┤│ +│ │ NewThreadPage │ Displayed when creating new session││ +│ ├─────────────────────────────────────────────────────────┤│ +│ │ ChatPage │ Displayed during active session ││ +│ └─────────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ WindowSideBar │ Agent filter + Session list ││ +│ └─────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +### 1.2 Core Principles + +1. **Unidirectional Data Flow**: Store → Composable → Component +2. **State Machine Driven**: Page transitions controlled by PageState state machine +3. **Presenter Pattern**: All business logic handled through Main process Presenters +4. **Reactive Design**: Components only handle UI rendering, state managed by Stores + +--- + +## 2. Directory Structure + +``` +src/renderer/src/ +├── views/ +│ └── ChatTabView.vue # Page entry, state machine logic +├── pages/ # Page components (new directory) +│ ├── WelcomePage.vue # Welcome page +│ ├── NewThreadPage.vue # New thread page +│ └── ChatPage.vue # Chat page +├── components/ +│ ├── sidebar/ # Sidebar components (new directory) +│ │ ├── AgentFilter.vue # Agent filter +│ │ ├── SessionGroup.vue # Session group +│ │ ├── SessionItem.vue # Session item +│ │ └── SidebarActions.vue # Sidebar action buttons +│ ├── chat/ # Chat related components (new directory) +│ │ ├── ChatTopBar.vue # Chat top bar +│ │ ├── MessageList.vue # Message list +│ │ ├── MessageItem.vue # Message item +│ │ ├── ChatInput.vue # Input box +│ │ ├── InputToolbar.vue # Input toolbar +│ │ └── ChatStatusBar.vue # Status bar +│ └── common/ # Common components +│ ├── ProjectSelector.vue # Project selector +│ ├── ModelSelector.vue # Model selector +│ └── PermissionSelector.vue # Permission selector +├── stores/ +│ ├── ui/ # UI related Stores (new directory) +│ │ ├── pageState.ts # Page state machine +│ │ ├── agent.ts # Agent management +│ │ ├── session.ts # Session management +│ │ └── project.ts # Project management +│ └── chat.ts # Retained, message related logic +├── composables/ +│ ├── usePageState.ts # Page state management +│ ├── useAgentFilter.ts # Agent filter logic +│ ├── useSessionGroup.ts # Session grouping logic +│ └── useProjectRecent.ts # Recent projects logic +└── types/ + └── ui.ts # UI type definitions +``` + +--- + +## 3. State Management Layer + +### 3.1 Page State Machine (pageState.ts) + +**Responsibility**: Manage global page state, control page transition logic + +**State Definition**: +```typescript +type PageState = + | { type: 'welcome' } + | { type: 'newThread' } + | { type: 'chat'; sessionId: string } +``` + +**State Transition Triggers**: +- On startup, check Provider config → welcome / newThread +- Create session → chat +- Switch session → chat (update sessionId) +- Close session → newThread +- Delete last Provider → welcome + +**Implementation Points**: +- Use Pinia store to manage state +- Provide `transitionTo(state)` method +- Provide `initialize()` method for startup initialization + +### 3.2 Agent Store (agent.ts) + +**Responsibility**: Manage Agent list and Agent filter state + +**Data Structure**: +```typescript +interface Agent { + id: string + name: string + type: 'deepchat' | 'acp' + enabled: boolean + icon?: string +} + +interface AgentState { + agents: Agent[] + selectedAgentId: string | null // null means "All Agents" + loading: boolean +} +``` + +**Data Sources**: +- DeepChat Agent: Always exists +- ACP Agents: Get from `configPresenter.getAcpAgents()` + +### 3.3 Session Store (session.ts) + +**Responsibility**: Manage session list and session grouping + +**Data Structure**: +```typescript +interface Session { + id: string + title: string + agentId: string + status: 'completed' | 'working' | 'error' | 'none' + projectDir: string + providerId?: string + modelId?: string + activeSkills?: string[] + createdAt: number + updatedAt: number +} + +type SessionGroup = + | { type: 'time'; label: string; sessions: Session[] } + | { type: 'project'; project: Project; sessions: Session[] } + +interface SessionState { + sessions: Session[] + activeSessionId: string | null + groupByProject: boolean + loading: boolean +} +``` + +**Key Actions**: +- `fetchSessions()`: Get session list from sessionPresenter +- `createSession(settings)`: Create new session +- `selectSession(id)`: Select session +- `closeSession()`: Close current session +- `toggleGroupMode()`: Toggle grouping mode + +### 3.4 Project Store (project.ts) + +**Responsibility**: Manage recent project list + +**Data Structure**: +```typescript +interface Project { + path: string + name: string + lastAccessedAt: number + sessionCount: number +} + +interface ProjectState { + projects: Project[] + loading: boolean +} +``` + +**Data Sources**: +- Aggregated from session list +- Support manual addition via folder picker + +--- + +## 4. Page Component Layer + +### 4.1 ChatTabView.vue + +**Responsibility**: Page entry, render corresponding page based on PageState + +**Implementation**: +```vue + +``` + +**Initialization Flow**: +1. Call `pageState.initialize()` on component mount +2. Check Provider configuration +3. Check if there's an active session +4. Determine initial page state + +### 4.2 WelcomePage.vue + +**Responsibility**: Guide user to configure first Provider + +**Data Dependencies**: +- ProviderStore: Get recommended Provider list + +**Interactions**: +- Click Provider → Call `windowPresenter.openOrFocusSettingsTab()` +- Click ACP Agent entry → Same as above + +### 4.3 NewThreadPage.vue + +**Responsibility**: New session entry + +**Data Dependencies**: +- ProjectStore: Recent project list +- AgentStore: Agent selection +- ModelStore: Model selection (DeepChat Agent only) + +**Subcomponents**: +- ProjectSelector: Project/folder selection +- ChatInput: Message input +- ChatStatusBar: Model/permission configuration + +**Interactions**: +- Send message → Call `sessionStore.createSession()` → Page transitions to ChatPage + +### 4.4 ChatPage.vue + +**Responsibility**: Main interface during active session + +**Data Dependencies**: +- SessionStore: Current session info +- ChatStore: Message list + +**Subcomponents**: +- ChatTopBar: Title, project, share, more actions +- MessageList: Message rendering +- ChatInput: Message input +- ChatStatusBar: Current configuration display + +--- + +## 5. Sidebar Component Layer + +### 5.1 WindowSideBar.vue Refactoring + +**Structure**: +``` +┌─────────────────────────────────┐ +│ AgentFilter (icon column) │ +│ - All Agents │ +│ - DeepChat │ +│ - Claude Code │ +│ - ... │ +├─────────────────────────────────┤ +│ SessionList │ +│ - SessionGroup (Today) │ +│ - SessionItem │ +│ - SessionItem │ +│ - SessionGroup (Yesterday) │ +│ - SessionItem │ +├─────────────────────────────────┤ +│ SidebarActions │ +│ - New Chat │ +│ - Toggle Group Mode │ +│ - Collapse │ +│ - Browser │ +│ - Settings │ +└─────────────────────────────────┘ +``` + +**Implementation**: +- Split into independent subcomponents +- Share state via composables +- Support collapsed/expanded modes + +### 5.2 AgentFilter.vue + +**Responsibility**: Agent icon list, filter sessions + +**Props**: None (get data from AgentStore) + +**Events**: +- `@select`: Triggered when Agent is selected + +### 5.3 SessionGroup.vue + +**Responsibility**: Session group display + +**Props**: +- `group: SessionGroup`: Group data + +**Slots**: +- `default`: SessionItem rendering + +### 5.4 SessionItem.vue + +**Responsibility**: Single session item rendering + +**Props**: +- `session: Session`: Session data +- `active: boolean`: Whether active + +**Events**: +- `@click`: Click session + +--- + +## 6. Chat Component Layer + +### 6.1 ChatTopBar.vue + +**Responsibility**: Chat top info bar + +**Props**: +- `title: string`: Session title +- `projectPath: string`: Project path + +**Slots**: +- Right action buttons area + +### 6.2 MessageList.vue + +**Responsibility**: Message list rendering + +**Key Features**: +- Virtual scrolling support (large message count optimization) +- Scroll to specific message +- Message preloading + +**Implementation Points**: +- Reuse core logic from existing `MessageList.vue` +- Adapt to new data structures + +### 6.3 ChatInput.vue + +**Responsibility**: Message input area + +**Key Features**: +- @ mention files +- / commands +- Multi-line input +- Attachment upload + +**Implementation Points**: +- Reuse existing `ChatInput.vue` component +- Adapt to new page structure + +--- + +## 7. Development Phases + +### Phase 1: Basic Framework (1-2 weeks) + +**Goal**: Build page skeleton and state management + +**Tasks**: +1. Create directory structure +2. Implement `pageState.ts` state machine +3. Implement `agent.ts` Store +4. Implement basic `session.ts` Store structure +5. Refactor `ChatTabView.vue` page switching logic +6. Create `WelcomePage.vue` static page + +**Acceptance Criteria**: +- Pages correctly display Welcome / NewThread / Chat states +- State transition logic is correct + +### Phase 2: Sidebar Functionality (1-2 weeks) + +**Goal**: Complete sidebar interaction + +**Tasks**: +1. Refactor `WindowSideBar.vue` +2. Implement `AgentFilter.vue` +3. Implement `SessionGroup.vue` / `SessionItem.vue` +4. Implement `useSessionGroup.ts` grouping logic +5. Implement `project.ts` Store +6. Implement `SidebarActions.vue` + +**Acceptance Criteria**: +- Sidebar correctly displays Agent filter +- Session list grouped by time/project +- Clicking session correctly switches page + +### Phase 3: NewThread Page (1 week) + +**Goal**: Complete new session functionality + +**Tasks**: +1. Implement `ProjectSelector.vue` +2. Implement permission selector +3. Integrate ChatInput component +4. Implement session creation logic + +**Acceptance Criteria**: +- Can select project/folder +- Can select Agent and model +- Session is created and navigates correctly after sending message + +### Phase 4: Chat Page (1-2 weeks) + +**Goal**: Complete session interaction + +**Tasks**: +1. Implement `ChatTopBar.vue` +2. Integrate existing `MessageList.vue` +3. Integrate existing `ChatInput.vue` +4. Implement `ChatStatusBar.vue` +5. Implement message send/receive logic + +**Acceptance Criteria**: +- Chat page correctly displays title and project +- Message list renders correctly +- Can send and receive messages + +### Phase 5: Optimization & Polish (1 week) + +**Goal**: Performance optimization and detail refinement + +**Tasks**: +1. Performance optimization (virtual scrolling, lazy loading) +2. Animation transition effects +3. Error handling and edge cases +4. Internationalization support + +--- + +## 8. Data Flow Design + +### 8.1 Initialization Flow + +``` +App Mounted + │ + ▼ +pageState.initialize() + │ + ├── providerStore.hasEnabledProviders()? + │ │ + │ ├── No → transitionTo('welcome') + │ │ + │ └── Yes ↓ + │ + ├── sessionStore.hasActiveSession()? + │ │ + │ ├── Yes → transitionTo('chat', sessionId) + │ │ + │ └── No → transitionTo('newThread') + │ + └── agentStore.fetchAgents() + projectStore.fetchProjects() +``` + +### 8.2 Create Session Flow + +``` +NewThreadPage: User sends message + │ + ▼ +sessionStore.createSession(settings) + │ + ├── Call sessionPresenter.createConversation() + │ + ├── Update local sessions list + │ + └── pageState.transitionTo('chat', newSessionId) + │ + ▼ + ChatPage renders + │ + ▼ + agentPresenter.sendMessage() +``` + +### 8.3 Session Switch Flow + +``` +Sidebar: Click session item + │ + ▼ +sessionStore.selectSession(id) + │ + ├── Call sessionPresenter.setActiveConversation() + │ + └── pageState.transitionTo('chat', id) +``` + +--- + +## 9. Relationship with Existing Code + +### 9.1 Reusable Components + +| Component | Reuse Level | Notes | +|-----------|-------------|-------| +| ChatInput.vue | High reuse | Core input logic unchanged, adapt to new structure | +| MessageList.vue | High reuse | Message rendering logic unchanged | +| MessageItem*.vue | High reuse | Message item components unchanged | +| MarkdownRenderer.vue | Full reuse | No modification needed | +| Artifact*.vue | Full reuse | No modification needed | + +### 9.2 Reusable Stores + +| Store | Reuse Level | Notes | +|-------|-------------|-------| +| chat.ts | Partial reuse | Message logic retained, session management migrated to new session.ts | +| providerStore.ts | Full reuse | No modification needed | +| modelStore.ts | Full reuse | No modification needed | + +### 9.3 New vs Modified + +**New**: +- `stores/ui/pageState.ts` +- `stores/ui/agent.ts` +- `stores/ui/session.ts` +- `stores/ui/project.ts` +- `pages/*.vue` +- `components/sidebar/*.vue` +- `components/chat/*.vue` (partial) + +**Modified**: +- `ChatTabView.vue`: Refactor page switching logic +- `WindowSideBar.vue`: Refactor sidebar structure +- `chat.ts`: Remove session management logic, keep message logic + +**Deprecated**: +- `components/mock/*.vue`: Mock components can be deleted after completion +- `composables/useMockViewState.ts`: Mock state management can be deleted + +--- + +## 10. Testing Strategy + +### 10.1 Unit Tests + +**Store Tests**: +- `pageState.ts`: State transition logic +- `session.ts`: Session CRUD operations +- `agent.ts`: Agent filter logic + +**Composable Tests**: +- `useSessionGroup.ts`: Grouping calculation logic + +### 10.2 Component Tests + +- `WelcomePage.vue`: Snapshot test +- `NewThreadPage.vue`: Interaction test +- `ChatPage.vue`: Interaction test +- `SessionItem.vue`: Render test + +### 10.3 Integration Tests + +- Complete session creation flow +- Session switch flow +- Page state transition flow + +--- + +## 11. Risks and Considerations + +### 11.1 Risk Points + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Increased state management complexity | Medium | Use Pinia devtools for debugging | +| Page switching performance | Low | Use keep-alive to cache page state | +| Conflict with existing code | Medium | Create new files, gradual migration | +| Missing i18n | Low | Add i18n keys during development | + +### 11.2 Considerations + +1. **Progressive Migration**: Build new structure first, then gradually migrate functionality +2. **Maintain Compatibility**: Keep old UI available until new UI is complete +3. **Performance First**: Use virtual scrolling for large lists +4. **Type Safety**: Use TypeScript strict mode for all new code + +--- + +## 12. Summary + +This plan is based on the product architecture defined in `ui-architecture.md`, using a clear layered design: + +1. **State Layer**: 4 core Stores managing page, Agent, session, and project states +2. **Page Layer**: 3 page states, driven by state machine transitions +3. **Component Layer**: Fine-grained component splitting for sidebar and chat areas + +Development cycle estimated at 6-8 weeks, implemented progressively in 5 phases. diff --git a/docs/specs/default-model-settings/plan.md b/docs/specs/default-model-settings/plan.md new file mode 100644 index 000000000..5c258f9c0 --- /dev/null +++ b/docs/specs/default-model-settings/plan.md @@ -0,0 +1,127 @@ +# 默认模型与默认视觉模型实施计划 + +## 1. 当前实现基线 + +### 1.1 新建会话模型来源(现状) + +1. `src/renderer/src/components/NewThread.vue` 初始化模型时,优先“最近会话/偏好模型/第一个可用模型”。 +2. `src/main/presenter/sessionPresenter/managers/conversationManager.ts` 在 `createConversation()` 中默认继承最近会话 `settings`。 +3. 因此当前“新建会话默认模型”不稳定,会受最近会话影响。 + +### 1.2 imageServer 模型来源(现状) + +1. `src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts` 构造函数接收 `provider/model`。 +2. `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts` 通过 `new ImageServer(args[0], args[1])` 传入。 +3. `src/renderer/src/components/mcp-config/mcpServerForm.vue` 存在 `imageServer` 专属模型选择 UI,并把选择写入 server `args`。 + +## 2. 设计决策 + +### 2.1 设置数据结构 + +新增两个设置键(存储于 `app-settings`): + +1. `defaultModel: { providerId: string; modelId: string }` +2. `defaultVisionModel: { providerId: string; modelId: string }` + +说明: + +1. 两者均通过现有 `configPresenter.getSetting/setSetting` 访问。 +2. 不新增独立 store 文件,先沿用现有配置存储体系。 + +### 2.2 新建会话默认模型决策 + +会话创建链路分两层处理: + +1. **Renderer 层(UI 体验)**:`NewThread.vue` 初始化时优先读 `defaultModel`(非 ACP)。 +2. **Main 层(最终兜底)**:`conversationManager.createConversation` 在调用方未显式传 `providerId/modelId` 时应用 `defaultModel`(非 ACP)。 + +规则: + +1. 显式传入 `providerId/modelId` 时不覆盖。 +2. `chatMode === 'acp agent'` 或目标 provider 为 `acp` 时不应用 `defaultModel`。 +3. `defaultModel` 未配置或无效时,回退到现有逻辑(保持兼容)。 + +### 2.3 默认视觉模型决策 + +1. `defaultVisionModel` 选择器只展示 `vision=true` 的已启用模型。 +2. 保存时做前置校验(非视觉模型不可保存)。 +3. `imageServer` 运行时读取 `defaultVisionModel`;不再依赖 `args`。 + +### 2.4 imageServer 架构调整 + +目标模块: + +1. `src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts` +2. `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts` +3. `src/renderer/src/components/mcp-config/mcpServerForm.vue` + +调整方式: + +1. `ImageServer` 构造函数去掉 provider/model 参数。 +2. 每次视觉调用时动态读取 `defaultVisionModel` 并校验可用性。 +3. `mcpServerForm.vue` 删除 `imageServer` 专属模型选择与 args 反解析逻辑。 +4. `builder.ts` 改为 `new ImageServer()`。 + +### 2.5 兼容与迁移策略 + +1. 保留旧 `imageServer.args` 数据但不再使用(兼容读取,不破坏旧配置文件结构)。 +2. 不做强制迁移脚本;缺失 `defaultVisionModel` 时由运行时错误提示引导用户配置。 + +## 3. 实施阶段 + +### Phase 1:配置与类型接入 + +1. 新增 `defaultModel/defaultVisionModel` 的读写与默认空值处理。 +2. 补充必要类型定义(若现有类型未覆盖)。 + +### Phase 2:新建会话默认模型 + +1. 调整 `NewThread.vue` 初始化优先级(`defaultModel` 优先)。 +2. 调整 `conversationManager.createConversation` 的兜底模型决策。 +3. 校验 `fork` 路径未被覆盖。 + +### Phase 3:默认视觉模型与 imageServer + +1. 设置页新增 `defaultVisionModel` 选择项(vision-only)。 +2. 移除 `mcpServerForm.vue` 中 `imageServer` 模型配置 UI 与 args 绑定逻辑。 +3. `imageServer` 改为全局读取 `defaultVisionModel`。 +4. `builder.ts` 去除 `args[0]/args[1]` 注入。 + +### Phase 4:验证与收尾 + +1. 回归新建会话路径(UI 创建、主进程创建)。 +2. 回归 `imageServer` 调用成功与失败场景。 +3. 统一补 i18n 文案与错误提示。 + +## 4. 测试策略 + +### 4.1 Main 测试 + +1. `createConversation`:无显式模型时应用 `defaultModel`。 +2. `createConversation`:ACP 模式不应用 `defaultModel`。 +3. `forkConversation`:继承行为不变。 +4. `imageServer`:读取 `defaultVisionModel` 成功/缺失/无效分支。 + +### 4.2 Renderer 测试 + +1. `NewThread` 初始化模型优先级验证(`defaultModel` 优先)。 +2. 设置页视觉模型选择仅展示 vision 模型。 +3. `mcpServerForm` 不再展示 `imageServer` 模型选择控件。 + +## 5. 风险与缓解 + +1. 风险:部分隐式创建会话路径未经过 UI,仍可能走旧默认。 +缓解:在 `conversationManager.createConversation` 做主进程兜底。 + +2. 风险:用户升级后未配置 `defaultVisionModel` 导致 imageServer 报错。 +缓解:统一错误文案,明确引导至设置页。 + +3. 风险:`defaultModel` 与 `preferredModel` 语义冲突。 +缓解:明确优先级为 `defaultModel > preferredModel`(仅非 ACP)。 + +## 6. 质量门槛 + +1. `pnpm run format` +2. `pnpm run lint` +3. `pnpm run typecheck` +4. 关键 main/renderer 测试通过 diff --git a/docs/specs/default-model-settings/spec.md b/docs/specs/default-model-settings/spec.md new file mode 100644 index 000000000..289bcd901 --- /dev/null +++ b/docs/specs/default-model-settings/spec.md @@ -0,0 +1,89 @@ +# 默认模型与默认视觉模型规格 + +## 概述 + +新增两个全局设置项: + +1. `默认模型`(`defaultModel`) +2. `默认视觉模型`(`defaultVisionModel`) + +其中: + +1. `默认模型`用于所有“新建会话”默认模型选择(`fork` 例外,`acp` 模式例外)。 +2. `默认视觉模型`用于视觉场景,当前仅供内置 `imageServer` 使用。 +3. `imageServer` 现有的“按服务器 args 配置模型”能力移除,统一改为读取全局 `defaultVisionModel`。 +4. `默认视觉模型`只能选择具备 `vision` 能力的模型。 + +## 背景与动机 + +1. 当前新建会话模型会受到“最近会话/偏好模型”影响,缺少稳定的全局默认入口。 +2. `imageServer` 以 MCP 服务器局部参数维护模型,配置分散,和全局模型管理不一致。 +3. 视觉模型应统一做能力约束(`vision=true`),避免运行时才发现模型不支持图像输入。 + +## 用户故事 + +### US-1:新建会话统一默认模型 + +作为用户,我希望设置一次“默认模型”,以后新建会话时自动使用它,而不是被最近会话模型影响。 + +### US-2:ACP 模式不受影响 + +作为用户,我希望 ACP 会话仍按 ACP 机制选模型,不被“默认模型”覆盖。 + +### US-3:视觉能力统一入口 + +作为用户,我希望设置一个“默认视觉模型”,内置图片工具直接使用它,不再在 `imageServer` 里重复配置。 + +## 功能需求 + +### A. 新增全局设置项 + +- [ ] 新增 `defaultModel` 配置,数据结构为 `{ providerId: string, modelId: string }` +- [ ] 新增 `defaultVisionModel` 配置,数据结构为 `{ providerId: string, modelId: string }` +- [ ] 两项配置均通过 `configPresenter.getSetting/setSetting` 读写并持久化 + +### B. 新建会话默认模型规则 + +- [ ] 适用范围:所有“新建会话”路径(即调用 `createConversation` 创建新会话) +- [ ] 排除范围:`forkConversation`(以及基于分支语义的会话继承路径)不改,继续继承源会话模型 +- [ ] ACP 例外:当会话处于 `acp agent` 模式时,不应用 `defaultModel` +- [ ] 优先级:当调用方未显式传入 `providerId/modelId` 时,`defaultModel` 优先于“最近会话/旧偏好模型”逻辑 +- [ ] 当 `defaultModel` 未配置或已失效时,回退到当前现有兜底策略 + +### C. 默认视觉模型规则 + +- [ ] `defaultVisionModel` 的候选列表仅允许 `vision=true` 的已启用模型 +- [ ] 若用户尝试保存非视觉模型,需阻止并给出明确提示 +- [ ] 若 `defaultVisionModel` 未配置或失效,视觉调用返回可读错误并引导去设置页配置 + +### D. imageServer 统一使用全局视觉模型 + +- [ ] `imageServer` 不再从 MCP server `args` 读取 provider/model +- [ ] `imageServer` 每次视觉调用前从全局配置读取 `defaultVisionModel` +- [ ] `inMemoryServers/builder.ts` 中 `imageServer` 构造不再依赖 `args[0]/args[1]` +- [ ] MCP 配置表单中针对 `imageServer` 的模型选择 UI 移除 + +### E. 验收标准 + +- [ ] 在非 ACP 新建会话中,未手动改模型时默认使用 `defaultModel` +- [ ] `fork` 新会话继续继承原会话模型,不受 `defaultModel` 干预 +- [ ] ACP 新建会话不受 `defaultModel` 影响 +- [ ] `imageServer` 在已配置 `defaultVisionModel` 时可正常调用视觉能力 +- [ ] `imageServer` 在未配置/配置无效时给出明确错误(非静默失败) +- [ ] `imageServer` 相关 MCP args 模型配置入口已移除 + +## 非目标 + +1. 不改标题生成链路的模型选择策略(本次仅新增会话默认模型与视觉默认模型)。 +2. 不新增“按工具分别配置视觉模型”的能力(仅一个全局视觉模型)。 +3. 不修改 ACP 模型管理机制。 + +## 约束 + +1. 保持现有 Presenter 架构与 IPC 类型边界,不引入新通信通道。 +2. 保持设置持久化兼容,旧配置文件可继续加载。 +3. UI 文案必须走 i18n。 + +## 开放问题 + +无。 diff --git a/docs/specs/default-model-settings/tasks.md b/docs/specs/default-model-settings/tasks.md new file mode 100644 index 000000000..ff0926878 --- /dev/null +++ b/docs/specs/default-model-settings/tasks.md @@ -0,0 +1,57 @@ +# 默认模型与默认视觉模型 Tasks + +## T0 规格确认 + +- [x] 完成 `spec.md` +- [x] 完成 `plan.md` +- [x] 完成 `tasks.md` + +## T1 配置层 + +- [ ] 在配置体系新增 `defaultModel` 设置读写(`providerId/modelId`)。 +- [ ] 在配置体系新增 `defaultVisionModel` 设置读写(`providerId/modelId`)。 +- [ ] 为设置页提供读取/保存接口(复用 `configPresenter.getSetting/setSetting`)。 + +## T2 新建会话默认模型(Renderer + Main) + +- [ ] 调整 `src/renderer/src/components/NewThread.vue` 初始化模型优先级:非 ACP 时优先 `defaultModel`。 +- [ ] 保持手动选模可覆盖默认值(仅默认初始化受影响)。 +- [ ] 在 `src/main/presenter/sessionPresenter/managers/conversationManager.ts` 中补主进程兜底:未显式模型且非 ACP 时应用 `defaultModel`。 +- [ ] 验证主进程自动建会话入口(如 in-memory server 调用 `createConversation`)同样生效。 +- [ ] 验证 `forkConversation` 路径不受影响。 + +## T3 设置页 UI + +- [ ] 在设置页新增“默认模型”选择项(全模型,排除 ACP provider)。 +- [ ] 在设置页新增“默认视觉模型”选择项(仅 `vision=true`)。 +- [ ] 补齐 i18n 文案(至少 `zh-CN` + `en-US`)。 +- [ ] 视觉模型保存时增加校验与错误提示。 + +## T4 imageServer 改造 + +- [ ] 修改 `src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts`:移除构造注入 provider/model,改为运行时读取 `defaultVisionModel`。 +- [ ] 修改 `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts`:`imageServer` 改为无参构造。 +- [ ] 修改 `src/renderer/src/components/mcp-config/mcpServerForm.vue`:删除 `imageServer` 模型选择 UI、args 反解析与写回逻辑。 +- [ ] 保持其他 inmemory server 的 args 行为不变。 + +## T5 失败处理与提示 + +- [ ] `defaultVisionModel` 缺失时,`imageServer` 返回可读错误。 +- [ ] `defaultVisionModel` 指向非视觉或不可用模型时,`imageServer` 返回可读错误。 +- [ ] 错误提示文案包含“去设置中配置默认视觉模型”。 + +## T6 测试 + +- [ ] Main:`createConversation` 非 ACP 默认模型应用测试。 +- [ ] Main:ACP 场景不应用默认模型测试。 +- [ ] Main:`forkConversation` 不受影响测试。 +- [ ] Main:`imageServer` 读取 `defaultVisionModel` 成功/失败测试。 +- [ ] Renderer:默认视觉模型只展示 vision 模型测试。 +- [ ] Renderer:`mcpServerForm` 不再出现 `imageServer` 专属模型配置测试。 + +## T7 质量检查 + +- [ ] `pnpm run format` +- [ ] `pnpm run lint` +- [ ] `pnpm run typecheck` +- [ ] 跑相关测试并记录结果 diff --git a/docs/specs/new-agent/plan.md b/docs/specs/new-agent/plan.md new file mode 100644 index 000000000..4ad4d7b64 --- /dev/null +++ b/docs/specs/new-agent/plan.md @@ -0,0 +1,410 @@ +# New Agent Architecture v0 — Implementation Plan + +## 1. Current Implementation Baseline + +### 1.1 LLM Provider (reuse as-is) + +- `src/main/presenter/llmProviderPresenter/baseProvider.ts` — `BaseLLMProvider` with `coreStream()` returning `AsyncGenerator` +- `src/shared/types/core/llm-events.ts` — `LLMCoreStreamEvent` discriminated union (text, reasoning, tool_call_start, error, usage, stop, etc.) +- Provider instances managed by `LLMProviderPresenter.getProviderInstance(providerId)` + +### 1.2 Event System (extend, not replace) + +- `src/main/eventbus.ts` — `EventBus` singleton with `sendToRenderer(event, target, payload)` +- `src/main/events.ts` — existing event constants (CONVERSATION_EVENTS, STREAM_EVENTS) +- `src/renderer/src/events.ts` — renderer-side event constant mirrors + +### 1.3 Presenter Registration + +- `src/main/presenter/index.ts` — singleton `Presenter` class, IPC handler at line ~358 does `presenter[name as keyof Presenter]` +- `src/shared/types/presenters/legacy.presenters.d.ts` — `IPresenter` interface +- `src/renderer/src/composables/usePresenter.ts` — proxy-based IPC caller + +### 1.4 SQLite + +- `src/main/presenter/sqlitePresenter/index.ts` — shared SQLite instance +- `src/main/presenter/sqlitePresenter/tables/` — table definitions with migration support +- Migration pattern: each table file exports `createTable()` + `migrations` array + +### 1.5 Config + +- `src/main/presenter/configPresenter/index.ts` — `getSetting()`, `getEnabledProviders()`, model defaults +- Default provider/model resolved via `configPresenter.getSetting('DEFAULT_PROVIDER_ID')` and `configPresenter.getSetting('DEFAULT_MODEL_ID')` + +## 2. Design Decisions + +### 2.1 Naming: `newAgentPresenter` not `agentPresenter` + +The old `agentPresenter` still exists and must keep working. The new one is registered as `newAgentPresenter` in IPresenter to avoid name collision. Renderer calls `usePresenter('newAgentPresenter')`. When old UI is removed, rename to `agentPresenter`. + +### 2.2 Database: new tables in same chat.db + +No separate DB file. New tables coexist with old tables in `chat.db`: +- `new_sessions` — agentPresenter's thin session registry +- `new_projects` — project directory history +- `deepchat_sessions` — deepchatAgentPresenter's session config +- `deepchat_messages` — deepchatAgentPresenter's messages + +Prefix `new_` on `sessions` and `projects` to avoid any conflict with potential future SQLite reserved words or collisions. + +### 2.3 Stream handling: reuse LLMCoreStreamEvent, transform to LLMAgentEventData + +deepchatAgentPresenter consumes `LLMCoreStreamEvent` from `coreStream()` and: +1. Accumulates content into `AssistantMessageBlock[]` structure +2. Persists structured JSON content to `deepchat_messages` (batched DB writes every 600ms) +3. Transforms to `LLMAgentEventData` format with `conversationId` + `eventId` for routing +4. Emits via EventBus (batched renderer flush every 120ms) — agentPresenter relays to renderer + +This means the renderer's stream handling code receives the same event format. Same `LLMAgentEventData` structure. + +### 2.4 v0 message content: structured JSON from the start + +Even in v0, messages use structured JSON content — not plain text. This avoids a migration when adding tool calls and thinking in later versions. + +**User messages** are stored as serialized `UserMessageContent`: +``` +{ text: "user input", files: [], links: [], search: false, think: false } +``` + +**Assistant messages** are stored as serialized `AssistantMessageBlock[]`: +``` +[{ type: "content", content: "LLM response text", status: "success", timestamp: 1234567890 }] +``` + +v0 only produces `content` and `reasoning_content` block types. Later versions add `tool_call`, `search`, `error`, etc. + +### 2.5 v0 message assembly: single user message, no context + +v0 sends exactly one message to the LLM: + +``` +messages = [ + { role: 'user', content: input.message } +] +``` + +No system prompt, no conversation history, no tool definitions. Just raw user message → LLM → streamed response. Multi-turn context assembly is v1. + +### 2.6 v0 model resolution + +Use the global default provider/model from configPresenter unless CreateSessionInput specifies overrides: + +1. `input.providerId ?? configPresenter.getSetting('DEFAULT_PROVIDER_ID')` +2. `input.modelId ?? configPresenter.getSetting('DEFAULT_MODEL_ID')` + +Per-session model config (temperature, system prompt, etc.) is not planned. Sessions use provider/model defaults from configPresenter. + +### 2.7 Message status lifecycle + +Messages use a three-state lifecycle: `pending` → `sent` | `error` + +- `pending` — message is being generated (stream in progress) +- `sent` — generation completed successfully +- `error` — generation failed or app crashed during generation + +**Crash recovery**: On app startup, any `deepchat_messages` rows with `status = 'pending'` are updated to `status = 'error'`. This handles the case where the app was killed during streaming. + +### 2.8 Stream batching + +Two independent batching intervals to balance responsiveness and write pressure: + +- **Renderer flush**: every 120ms — accumulate stream deltas and flush to renderer via EventBus +- **DB flush**: every 600ms — batch-write accumulated content to `deepchat_messages` table + +On stream end, both flush immediately with final content. + +## 3. Architecture: Module Breakdown + +### 3.1 Shared Types (`src/shared/types/`) + +**`agent-interface.d.ts`** — the protocol every agent implements: + +- `IAgentImplementation` interface with: `initSession`, `destroySession`, `getSessionState`, `processMessage`, `cancelGeneration`, `getMessages`, `getMessageIds`, `getMessage` +- v0 subset only — permissions, retry, edit added in later versions + +**`chat-types.d.ts`** — data model types: + +- `Agent`, `Session`, `SessionStatus`, `CreateSessionInput`, `ChatMessage`, `UserMessageContent`, `AssistantMessageBlock`, `MessageMetadata`, `Project` + +**`presenters/new-agent.presenter.d.ts`** — IPC-facing interface: + +- `INewAgentPresenter` — what the renderer can call: `createSession`, `sendMessage`, `getSessionList`, `getSession`, `getMessages`, `getMessageIds`, `getMessage`, `activateSession`, `deactivateSession`, `getActiveSession`, `getAgents`, `deleteSession` + +**`presenters/project.presenter.d.ts`** — IPC-facing interface: + +- `IProjectPresenter` — `getProjects`, `getRecentProjects`, `selectDirectory` + +### 3.2 New DB Tables (`src/main/presenter/sqlitePresenter/tables/`) + +**`newSessions.ts`** — thin session registry + +| Column | Type | Notes | +|--------|------|-------| +| id | TEXT PRIMARY KEY | UUID | +| agent_id | TEXT NOT NULL | `'deepchat'` for v0 | +| title | TEXT NOT NULL | | +| project_dir | TEXT | nullable | +| is_pinned | INTEGER DEFAULT 0 | | +| created_at | INTEGER NOT NULL | epoch ms | +| updated_at | INTEGER NOT NULL | epoch ms | + +**`newProjects.ts`** — project directory history + +| Column | Type | Notes | +|--------|------|-------| +| path | TEXT PRIMARY KEY | filesystem path | +| name | TEXT NOT NULL | directory basename | +| icon | TEXT DEFAULT NULL | base64 icon data | +| last_accessed_at | INTEGER NOT NULL | epoch ms | + +**`deepchatSessions.ts`** — deepchat agent session config + +| Column | Type | Notes | +|--------|------|-------| +| id | TEXT PRIMARY KEY | same ID as new_sessions | +| provider_id | TEXT NOT NULL | | +| model_id | TEXT NOT NULL | | + +**`deepchatMessages.ts`** — deepchat agent messages + +| Column | Type | Notes | +|--------|------|-------| +| id | TEXT PRIMARY KEY | UUID | +| session_id | TEXT NOT NULL | FK to new_sessions | +| order_seq | INTEGER NOT NULL | monotonic ordering within session | +| role | TEXT NOT NULL | `'user'` or `'assistant'` | +| content | TEXT NOT NULL | JSON string: `UserMessageContent` or `AssistantMessageBlock[]` | +| status | TEXT DEFAULT 'pending' | `'pending'`, `'sent'`, `'error'` | +| is_context_edge | INTEGER DEFAULT 0 | marks context window boundary (unused in v0) | +| metadata | TEXT DEFAULT '{}' | JSON string: token usage, timing, model info | +| created_at | INTEGER NOT NULL | epoch ms | +| updated_at | INTEGER NOT NULL | epoch ms | + +Index: `CREATE INDEX idx_deepchat_messages_session ON deepchat_messages(session_id, order_seq)` + +### 3.3 agentPresenter (`src/main/presenter/newAgentPresenter/`) + +**`index.ts`** — main presenter class implementing `INewAgentPresenter` + +Owns: +- `sessionManager` — thin CRUD over `new_sessions` table +- `messageManager` — proxy that resolves agentId then delegates to agent +- `agentRegistry` — `Map`, populated in constructor with deepchatAgentPresenter + +Methods (IPC-facing): +- `createSession(input, webContentsId)` → sessionManager.create() + agent.initSession() + agent.processMessage() + emit ACTIVATED +- `sendMessage(sessionId, content)` → resolve agent → agent.processMessage() +- `getSessionList(params)` → sessionManager.list() + enrich with agent.getSessionState() +- `getSession(sessionId)` → sessionManager.get() + agent.getSessionState() +- `getMessages(sessionId)` → resolve agent → agent.getMessages() +- `getMessageIds(sessionId)` → resolve agent → agent.getMessageIds() +- `getMessage(messageId)` → resolve agent → agent.getMessage() (needs sessionId lookup) +- `activateSession(webContentsId, sessionId)` → update window binding + emit ACTIVATED +- `deactivateSession(webContentsId)` → clear window binding + emit DEACTIVATED +- `getActiveSession(webContentsId)` → return bound session +- `getAgents()` → agentRegistry.getAll() +- `deleteSession(sessionId)` → agent.destroySession() + sessionManager.delete() + +Event relay: +- Listen to agent events (callback or EventEmitter pattern) +- Re-emit as SESSION_EVENTS / STREAM_EVENTS via EventBus to renderer +- All STREAM_EVENTS carry `conversationId` for renderer routing + +**`sessionManager.ts`** — thin session registry + +- `create(id, agentId, title, projectDir)` → INSERT into `new_sessions` +- `get(id)` → SELECT from `new_sessions`, returns thin record +- `list(filters)` → SELECT with optional WHERE agent_id / project_dir, ORDER BY updated_at DESC +- `update(id, fields)` → UPDATE `new_sessions` +- `delete(id)` → DELETE from `new_sessions` +- Window bindings: in-memory `Map` for webContentsId → sessionId + +**`messageManager.ts`** — proxy + +- `getMessages(sessionId)` → resolve agentId from sessionManager → agentRegistry.get(agentId).getMessages(sessionId) +- Same pattern for `getMessageIds`, `getMessage` + +**`agentRegistry.ts`** — agent discovery + +- `register(agentId, implementation)` — called in constructor +- `resolve(agentId)` → returns `IAgentImplementation` or throws +- `getAll()` → returns `Agent[]` list (for v0: just deepchat) + +### 3.4 deepchatAgentPresenter (`src/main/presenter/deepchatAgentPresenter/`) + +**`index.ts`** — implements `IAgentImplementation` + +Constructor receives: `llmProviderPresenter`, `configPresenter`, `sqlitePresenter` + +Initialization: +- Run crash recovery: `UPDATE deepchat_messages SET status = 'error' WHERE status = 'pending'` + +State: in-memory `Map` for runtime status + +Methods: +- `initSession(sessionId, config)` → INSERT into `deepchat_sessions`, set runtime status to `'idle'` +- `destroySession(sessionId)` → DELETE from `deepchat_sessions` + `deepchat_messages`, remove from in-memory map +- `getSessionState(sessionId)` → return `{ status, providerId, modelId }` from in-memory + DB +- `processMessage(sessionId, content)` → persist user message (status `'sent'`) → create assistant message (status `'pending'`) → call LLM → stream with batching → finalize assistant message (status `'sent'`) → emit events +- `cancelGeneration(sessionId)` → abort stream (v0: basic abort signal support) +- `getMessages(sessionId)` → SELECT from `deepchat_messages` WHERE session_id ORDER BY order_seq +- `getMessageIds(sessionId)` → SELECT id from `deepchat_messages` WHERE session_id ORDER BY order_seq +- `getMessage(messageId)` → SELECT from `deepchat_messages` WHERE id + +**`streamHandler.ts`** — LLM stream consumer + +Responsibility: consume `AsyncGenerator`, build structured `AssistantMessageBlock[]` content, persist with batching, emit events. + +Flow: +1. Get provider instance via `llmProviderPresenter.getProviderInstance(providerId)` +2. Build messages array (v0: just the user message as plain text) +3. Call `provider.coreStream(messages, modelId, modelConfig, temperature, maxTokens, tools=[])` +4. Iterate the async generator, accumulating into `AssistantMessageBlock[]`: + - On `text` event → append to current `content` block, queue renderer flush + - On `reasoning` event → append to current `reasoning_content` block, queue renderer flush + - On `usage` event → store in metadata + - On `stop` event → finalize all blocks, flush DB, set message status to `'sent'`, emit `stream:end` + - On `error` event → add `error` block, set message status to `'error'`, emit `stream:error` +5. Renderer batching: flush accumulated deltas every 120ms as `stream:response` with `LLMAgentEventData` (includes `conversationId`, `eventId`) +6. DB batching: write accumulated content JSON to `deepchat_messages` every 600ms +7. On stream end or error, flush both immediately + +**`sessionStore.ts`** — deepchat session persistence + +- CRUD over `deepchat_sessions` table (columns: id, provider_id, model_id) + +**`messageStore.ts`** — deepchat message persistence + +- CRUD over `deepchat_messages` table +- `createUserMessage(sessionId, orderSeq, content)` → INSERT with role='user', status='sent', content=JSON string of `UserMessageContent` +- `createAssistantMessage(sessionId, orderSeq)` → INSERT with role='assistant', status='pending', content='[]' +- `updateAssistantContent(messageId, contentJson)` → UPDATE content (batched) +- `finalizeAssistantMessage(messageId, contentJson, metadataJson)` → UPDATE content, status='sent', metadata +- `setMessageError(messageId, errorJson)` → UPDATE status='error', content with error block appended +- `recoverPendingMessages()` → UPDATE status='error' WHERE status='pending' (called on startup) +- `getNextOrderSeq(sessionId)` → SELECT MAX(order_seq) + 1 + +### 3.5 projectPresenter (`src/main/presenter/projectPresenter/`) + +**`index.ts`** — implements `IProjectPresenter` + +Constructor receives: `sqlitePresenter`, `devicePresenter` + +Methods: +- `getProjects()` → SELECT * FROM new_projects ORDER BY last_accessed_at DESC +- `getRecentProjects(limit)` → SELECT * FROM new_projects ORDER BY last_accessed_at DESC LIMIT ? +- `selectDirectory()` → `devicePresenter.selectDirectory()`, if selected upsert into new_projects, return path + +### 3.6 Presenter Registration (`src/main/presenter/index.ts`) + +3 touchpoints: + +1. Import and add to IPresenter: `newAgentPresenter: INewAgentPresenter`, `projectPresenter: IProjectPresenter` +2. Add class properties +3. Instantiate in constructor: + - `this.deepchatAgentPresenter = new DeepChatAgentPresenter(this.llmProviderPresenter, this.configPresenter, this.sqlitePresenter)` + - `this.newAgentPresenter = new NewAgentPresenter(this.deepchatAgentPresenter, this.configPresenter, this.sqlitePresenter, this.eventBus)` + - `this.projectPresenter = new ProjectPresenter(this.sqlitePresenter, this.devicePresenter)` + +Note: `deepchatAgentPresenter` is NOT exposed on IPresenter — it's internal. Only `newAgentPresenter` and `projectPresenter` are IPC-accessible. + +### 3.7 Events (`src/main/events.ts`) + +Add: + +``` +SESSION_EVENTS = { + LIST_UPDATED: 'session:list-updated', + ACTIVATED: 'session:activated', + DEACTIVATED: 'session:deactivated', + STATUS_CHANGED: 'session:status-changed', +} +``` + +STREAM_EVENTS reused as-is — same event names, same payload format. All stream events include `conversationId` for renderer-side routing. + +### 3.8 Renderer Stores (`src/renderer/src/stores/ui/`) + +**`session.ts`** — rewrite + +- Uses `usePresenter('newAgentPresenter')` +- State: `sessions: Session[]`, `activeSessionId`, `groupMode` +- Actions: `fetchSessions()`, `createSession(input)`, `selectSession(id)`, `closeSession()` +- Listens to: `SESSION_EVENTS.LIST_UPDATED`, `SESSION_EVENTS.ACTIVATED`, `SESSION_EVENTS.DEACTIVATED`, `SESSION_EVENTS.STATUS_CHANGED` + +**`message.ts`** — new + +- Uses `usePresenter('newAgentPresenter')` +- State: `messageIds: string[]`, `messageCache: Map`, `isStreaming: boolean`, `streamingBlocks: AssistantMessageBlock[]` +- Actions: `loadMessages(sessionId)`, `getMessage(id)` +- Listens to: `STREAM_EVENTS.RESPONSE` (update streaming blocks from `LLMAgentEventData`), `STREAM_EVENTS.END` (finalize), `STREAM_EVENTS.ERROR` +- Filters events by `conversationId` matching active session + +**`agent.ts`** — rewrite + +- Uses `usePresenter('newAgentPresenter')` +- State: `agents: Agent[]`, `selectedAgentId` +- Actions: `fetchAgents()`, `selectAgent(id)` + +**`project.ts`** — rewrite + +- Uses `usePresenter('projectPresenter')` +- State: `projects: Project[]`, `selectedProjectPath` +- Actions: `fetchProjects()`, `selectProject(path)`, `openFolderPicker()` + +**`draft.ts`** — new + +- State: `providerId`, `modelId`, `projectDir`, `agentId`, `reasoningEffort` +- Actions: `toCreateInput(message)` → `CreateSessionInput`, `reset()` + +### 3.9 NewThreadPage Integration + +- Imports `useSessionStore`, `useDraftStore`, `useMessageStore` +- On submit: `draftStore.toCreateInput(text)` → `sessionStore.createSession(input)` +- Session ACTIVATED event triggers message loading +- messageStore receives STREAM_EVENTS, filters by `conversationId`, and displays streaming blocks + +## 4. Test Strategy + +### 4.1 Unit Tests + +**agentPresenter:** +- `sessionManager.create/get/list/update/delete` — CRUD against in-memory SQLite +- `agentRegistry.register/resolve/getAll` — correct routing +- `createSession` → calls sessionManager.create + agent.initSession + agent.processMessage + +**deepchatAgentPresenter:** +- `processMessage` → creates user message (JSON content), calls LLM, creates assistant message (JSON blocks) +- `streamHandler` — given mock `AsyncGenerator`, verify: block accumulation, batched DB writes at 600ms, renderer flush at 120ms, final flush on stop +- `messageStore` — CRUD operations against in-memory SQLite, verify JSON content round-trip +- `messageStore.recoverPendingMessages()` — pending rows updated to error +- `messageStore.getNextOrderSeq()` — correct sequence calculation + +**projectPresenter:** +- `getRecentProjects` — returns correct order and limit +- `selectDirectory` — upserts on new selection + +### 4.2 Integration Tests + +- End-to-end: `newAgentPresenter.createSession()` → verify new_sessions row + deepchat_sessions row + deepchat_messages rows (with valid JSON content) + events emitted with conversationId +- Coexistence: old `sessionPresenter.createSession()` still works — old tables unaffected +- Crash recovery: insert pending message, reinitialize presenter, verify status changed to error + +## 5. Risks & Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| LLM provider internal API changes | Cannot call `coreStream()` directly | Check `BaseLLMProvider` is stable; if not, use `llmProviderPresenter` public method | +| Stream event format mismatch | Renderer can't display response | Reuse exact `LLMAgentEventData` format — same as old agentPresenter emits | +| DB migration conflicts with old tables | Data corruption | New tables use distinct names (`new_sessions`, `deepchat_*`) — zero overlap | +| Performance of batched message writes | Streaming feels laggy | DB flush every 600ms, renderer flush every 120ms — matches old `StreamUpdateScheduler` intervals | +| JSON content parsing errors | Messages unreadable | Validate JSON on write, wrap parse in try/catch on read, store raw text as fallback error block | + +## 6. Quality Gate + +- [ ] `pnpm run format` +- [ ] `pnpm run lint` +- [ ] `pnpm run typecheck` +- [ ] Unit tests pass for all new modules +- [ ] Integration test: create session + stream response end-to-end +- [ ] Old UI regression: `sessionPresenter.getSessionList()` returns same results as before diff --git a/docs/specs/new-agent/spec.md b/docs/specs/new-agent/spec.md new file mode 100644 index 000000000..0e99a155e --- /dev/null +++ b/docs/specs/new-agent/spec.md @@ -0,0 +1,212 @@ +# New Agent Architecture v0 — Minimal Single-Turn Chat + +## Overview + +Replace the old sessionPresenter + agentPresenter architecture with an agent-centric model. v0 delivers the minimum working system: a user can create a session, send one message, and see a streamed LLM response — all through the new architecture with new DB tables and new presenters. + +v0 is the foundation. It proves the architecture end-to-end. Subsequent versions (v1–v5) add multi-turn, tool calling, permissions, config, and ACP support incrementally. + +## Background + +See [Full-Stack Mismatch Analysis](../../architecture/new-ui-store-presenter-mismatch.md) for the complete problem analysis and architectural decisions, including: +- Why agents are the organizing principle (not sessions) +- Why new presenters coexist with old ones (not wrap/translate) +- The ownership model: agents own their data, agentPresenter owns the registry +- The "build new, grow forward" strategy + +## Goals + +1. **Agent interface protocol** — define the unified contract all agents implement +2. **agentPresenter** — router, thin session registry, event relay +3. **deepchatAgentPresenter** — single-turn chat: message → LLM → streamed response → persist +4. **projectPresenter** — thin project directory CRUD +5. **New DB tables** — `new_sessions`, `new_projects`, `deepchat_sessions`, `deepchat_messages` +6. **New renderer stores** — sessionStore, messageStore, agentStore, projectStore, draftStore +7. **NewThreadPage integration** — create session and see streamed response through new architecture + +## Non-Goals (deferred to later versions) + +- Multi-turn conversation context assembly (v1) +- Tool calling / MCP integration (v2) +- Permission and question flows (v3) +- ACP agent support (v5) +- Migration / backfill from old tables (separate task) +- Modifying or removing any old presenter or old UI code +- i18n for new components (follow-up) + +## Scope Boundary + +### New files to create + +**Main process:** +- `src/shared/types/agent-interface.d.ts` — agent interface protocol +- `src/shared/types/chat-types.d.ts` — Agent, Session, Project, CreateSessionInput, ChatMessage, MessageBlock types +- `src/main/presenter/newAgentPresenter/index.ts` — agentPresenter (router) +- `src/main/presenter/newAgentPresenter/sessionManager.ts` — thin session registry +- `src/main/presenter/newAgentPresenter/messageManager.ts` — message proxy +- `src/main/presenter/newAgentPresenter/agentRegistry.ts` — agent discovery + routing +- `src/main/presenter/deepchatAgentPresenter/index.ts` — deepchat agent implementation +- `src/main/presenter/deepchatAgentPresenter/sessionStore.ts` — agent-owned session persistence +- `src/main/presenter/deepchatAgentPresenter/messageStore.ts` — agent-owned message persistence +- `src/main/presenter/deepchatAgentPresenter/streamHandler.ts` — LLM stream → message persistence + event emission +- `src/main/presenter/projectPresenter/index.ts` — project CRUD +- `src/main/presenter/sqlitePresenter/tables/` — new table definitions +- `src/main/events.ts` — add SESSION_EVENTS + +**Renderer:** +- `src/renderer/src/stores/ui/session.ts` — rewrite +- `src/renderer/src/stores/ui/message.ts` — new +- `src/renderer/src/stores/ui/agent.ts` — rewrite +- `src/renderer/src/stores/ui/project.ts` — rewrite +- `src/renderer/src/stores/ui/draft.ts` — new + +### Existing files to modify + +- `src/shared/types/presenters/legacy.presenters.d.ts` — add to IPresenter interface +- `src/main/presenter/index.ts` — register new presenters (3 touchpoints) +- `src/renderer/src/pages/NewThreadPage.vue` — wire to new stores +- `src/renderer/src/events.ts` — add SESSION_EVENTS mirror + +### Explicitly NOT modified + +- All old presenters (sessionPresenter, old agentPresenter, threadPresenter) +- Old DB tables (conversations, messages) +- Legacy chatStore (`stores/chat.ts`) +- Old UI components that use legacy stores (ChatPage stays on old stores for now) +- LLM provider implementations (reused as-is) +- mcpPresenter (reused as-is, wired in v2) + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Agent interface protocol defined as TypeScript types in `src/shared/types/` +- [ ] agentPresenter registered in Presenter class, accessible via `usePresenter('newAgentPresenter')` +- [ ] agentPresenter.sessionManager creates session records in new `new_sessions` table +- [ ] agentPresenter.agentRegistry returns `[{ id: 'deepchat', name: 'DeepChat', type: 'deepchat', enabled: true }]` +- [ ] agentPresenter routes `sendMessage()` to deepchatAgentPresenter based on session's agentId +- [ ] deepchatAgentPresenter calls LLM provider's `coreStream()` and receives `LLMCoreStreamEvent` stream +- [ ] deepchatAgentPresenter persists user message and assistant message in `deepchat_messages` table with structured JSON content +- [ ] deepchatAgentPresenter emits stream events (response/end/error) via EventBus with `conversationId` for routing +- [ ] agentPresenter relays all stream events to renderer +- [ ] Stream events batched: 120ms flush to renderer, 600ms flush to DB +- [ ] On app restart, any messages with `status = 'pending'` are marked as `'error'` (crash recovery) +- [ ] projectPresenter reads/writes `new_projects` table +- [ ] NewThreadPage creates a session and displays streamed response through new architecture +- [ ] sessionStore displays session list in sidebar from new `new_sessions` table +- [ ] messageStore displays messages from new `deepchat_messages` table +- [ ] Old UI continues to work via old presenters — zero regression + +### Non-Functional Requirements + +- [ ] New tables created alongside old tables in same chat.db (no separate DB file) +- [ ] All new types in `src/shared/types/` — no type definitions in presenter files +- [ ] `pnpm run typecheck` passes +- [ ] `pnpm run lint` passes +- [ ] `pnpm run format` passes +- [ ] Unit tests for agentPresenter routing, deepchatAgentPresenter stream handling, sessionManager CRUD + +## Constraints + +- LLM provider HTTP clients are reused directly — deepchatAgentPresenter calls `BaseLLMProvider.coreStream()` and consumes the `AsyncGenerator` +- New tables live in the same SQLite database (`chat.db`) alongside old tables — no separate DB file +- IPC routing is dynamic (`presenter[name]`) — no route registration needed beyond the 3 touchpoints +- v0 sends a single user message and gets a single assistant response — no multi-turn context, no tool use +- Sessions use default model config from `configPresenter` — no per-session config overrides (by design, not deferred) + +## Data Model (v0 subset) + +### Agent + +- `id: string` — `'deepchat'` (only agent in v0) +- `name: string` — `'DeepChat'` +- `type: 'deepchat' | 'acp'` — `'deepchat'` +- `enabled: boolean` — `true` + +### Session (UI-facing) + +- `id: string` +- `title: string` +- `status: 'idle' | 'generating' | 'error'` — (`'waiting'` deferred to v3) +- `agentId: string` +- `projectDir: string | null` +- `providerId: string` +- `modelId: string` +- `isPinned: boolean` +- `createdAt: number` +- `updatedAt: number` + +### CreateSessionInput + +- `agentId: string` +- `message: string` +- `projectDir?: string` +- `providerId?: string` +- `modelId?: string` + +### Message (UI-facing, v0 subset) + +- `id: string` +- `sessionId: string` +- `orderSeq: number` — monotonic ordering within a session +- `role: 'user' | 'assistant'` +- `content: string` — JSON string in DB. For user: serialized `UserMessageContent`. For assistant: serialized `AssistantMessageBlock[]` +- `status: 'pending' | 'sent' | 'error'` — `pending` = generation in progress, `sent` = complete, `error` = failed or crash recovery +- `metadata: string` — JSON string for token usage, timing, model info +- `createdAt: number` +- `updatedAt: number` + +### UserMessageContent (stored as JSON) + +- `text: string` — the user's input text +- `files: MessageFile[]` — attached files (empty in v0) +- `links: string[]` — attached links (empty in v0) +- `search: boolean` — whether web search was requested +- `think: boolean` — whether thinking/reasoning was requested + +### AssistantMessageBlock (stored as JSON array) + +Each block has a `type` discriminator. v0 uses these block types: + +- `content` — text content from LLM response. Fields: `type`, `content`, `status`, `timestamp` +- `reasoning_content` — thinking/reasoning output. Fields: `type`, `content`, `status`, `timestamp`, `reasoning_time` +- `error` — error information. Fields: `type`, `content`, `status`, `timestamp` + +Additional block types added in later versions: `tool_call` (v2), `search` (v2), `action` (v2), `image` (v2) + +### MESSAGE_METADATA (stored in metadata JSON field) + +- `totalTokens: number` +- `inputTokens: number` +- `outputTokens: number` +- `generationTime: number` +- `firstTokenTime: number` +- `tokensPerSecond: number` +- `model?: string` +- `provider?: string` + +### Project + +- `path: string` +- `name: string` +- `icon: string | null` — base64 icon data +- `lastAccessedAt: number` + +## Event System (v0 subset) + +### SESSION_EVENTS (emitted by agentPresenter) + +- `session:list-updated` — session list changed +- `session:activated` — `{ webContentsId, sessionId }` +- `session:deactivated` — `{ webContentsId }` +- `session:status-changed` — `{ sessionId, status }` + +### STREAM_EVENTS (relayed from agents, unchanged format) + +- `stream:response` — streaming chunk, carries `LLMAgentEventData` with `conversationId` and `eventId` for routing +- `stream:end` — streaming complete, carries `{ conversationId }` +- `stream:error` — streaming failed, carries `{ conversationId, error }` + +## Open Questions + +None. All architectural decisions resolved in the mismatch analysis document. diff --git a/docs/specs/new-agent/tasks.md b/docs/specs/new-agent/tasks.md new file mode 100644 index 000000000..144c9cd32 --- /dev/null +++ b/docs/specs/new-agent/tasks.md @@ -0,0 +1,112 @@ +# New Agent Architecture — Tasks + +## T0 Shared Types & Events + +- [x] Create `src/shared/types/agent-interface.d.ts` — `IAgentImplementation`, `Agent`, `Session`, `SessionStatus`, `CreateSessionInput`, `ChatMessageRecord`, `UserMessageContent`, `AssistantMessageBlock`, `MessageMetadata`, `Project` (merged chat-types into this file) +- [x] Create `src/shared/types/presenters/new-agent.presenter.d.ts` — `INewAgentPresenter` interface +- [x] Create `src/shared/types/presenters/project.presenter.d.ts` — `IProjectPresenter` interface +- [x] Export new types from `src/shared/types/presenters/index.d.ts` +- [x] Add `SESSION_EVENTS` to `src/main/events.ts` (list-updated, activated, deactivated, status-changed) +- [x] Add `SESSION_EVENTS` mirror to `src/renderer/src/events.ts` + +## T1 New DB Tables + +- [x] Create `src/main/presenter/sqlitePresenter/tables/newSessions.ts` — `new_sessions` table (no provider_id/model_id — agent owns those) +- [x] Create `src/main/presenter/sqlitePresenter/tables/newProjects.ts` — `new_projects` table with `icon` column +- [x] Create `src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts` — `deepchat_sessions` table (id, provider_id, model_id only — no per-session config columns) +- [x] Create `src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts` — `deepchat_messages` table with order_seq, JSON content, status (pending/sent/error), is_context_edge, metadata; index on (session_id, order_seq) +- [x] Register new tables in `sqlitePresenter/index.ts` (initTables + migrate array) + +## T2 deepchatAgentPresenter + +- [x] Create `messageStore.ts` — CRUD over `deepchat_messages` +- [x] Create `sessionStore.ts` — CRUD over `deepchat_sessions` +- [x] Create `index.ts` — implements `IAgentImplementation`, wires sessionStore + messageStore + llmProviderPresenter, runs crash recovery on init +- [x] Unit tests: processMessage, recoverPendingMessages (`deepchatAgentPresenter.test.ts`) + +## T3 agentPresenter (newAgentPresenter) + +- [x] Create `src/main/presenter/newAgentPresenter/agentRegistry.ts` — register/resolve/getAll +- [x] Create `src/main/presenter/newAgentPresenter/sessionManager.ts` — CRUD over `new_sessions`, in-memory window bindings (webContentsId → sessionId) +- [x] Create `src/main/presenter/newAgentPresenter/messageManager.ts` — proxy resolves agentId then delegates to agent +- [x] Create `src/main/presenter/newAgentPresenter/index.ts` — implements `INewAgentPresenter`, wires sessionManager + messageManager + agentRegistry + event relay (all stream events carry conversationId) +- [x] Unit tests: sessionManager CRUD + window bindings (`test/main/presenter/newAgentPresenter/sessionManager.test.ts`) +- [x] Unit tests: agentRegistry register/resolve/getAll/has (`test/main/presenter/newAgentPresenter/agentRegistry.test.ts`) +- [x] Unit tests: messageManager delegation (`test/main/presenter/newAgentPresenter/messageManager.test.ts`) +- [x] Unit tests: createSession → verify sessionManager.create + agent.initSession + agent.processMessage called (`test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts`) +- [x] Unit tests: sendMessage → verify agent routing (`test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts`) + +## T4 projectPresenter + +- [x] Create `src/main/presenter/projectPresenter/index.ts` — implements `IProjectPresenter`, CRUD over `new_projects`, selectDirectory via devicePresenter +- [x] Unit tests: getProjects, getRecentProjects (order + limit), selectDirectory (`test/main/presenter/projectPresenter/projectPresenter.test.ts`) + +## T5 Presenter Registration + +- [x] Add `INewAgentPresenter` and `IProjectPresenter` to `IPresenter` interface in `src/shared/types/presenters/legacy.presenters.d.ts` +- [x] Add properties and constructor instantiation in `src/main/presenter/index.ts` +- [x] Verify: `usePresenter('newAgentPresenter')` and `usePresenter('projectPresenter')` callable from renderer + +## T6 Renderer Stores + +- [x] Rewrite `src/renderer/src/stores/ui/session.ts` — uses `newAgentPresenter`, listens to `SESSION_EVENTS`, uses `webContentsId` for activation +- [x] Create `src/renderer/src/stores/ui/message.ts` — uses `newAgentPresenter`, listens to `STREAM_EVENTS`, filters by conversationId, maintains streamingBlocks as AssistantMessageBlock[] +- [x] Rewrite `src/renderer/src/stores/ui/agent.ts` — uses `newAgentPresenter.getAgents()` +- [x] Rewrite `src/renderer/src/stores/ui/project.ts` — uses `projectPresenter` +- [x] Create `src/renderer/src/stores/ui/draft.ts` — pre-session config, toCreateInput() + +## T7 NewThreadPage Integration + +- [x] Update `src/renderer/src/pages/NewThreadPage.vue` — wire to new stores (removed `title` from CreateSessionInput, title derived from message in presenter) +- [x] Update `src/renderer/src/views/ChatTabView.vue` — `deriveFromSessions` → `fetchProjects` +- [x] Verify: type message → submit → session created → streaming response displayed with structured blocks +- [x] Verify: session appears in sidebar via sessionStore + +## T8 Quality Gate & Verification + +- [x] `pnpm run typecheck` — passes +- [x] `pnpm run lint` — passes (0 warnings, 0 errors) +- [x] `pnpm run format` — passes +- [x] Unit tests: all new modules passing +- [x] Integration test: createSession end-to-end — new_sessions row + deepchat_sessions row + deepchat_messages rows (valid JSON content) + events with conversationId (`test/main/presenter/newAgentPresenter/integration.test.ts`) +- [x] Integration test: crash recovery — insert pending message, reinit, verify status = error (`test/main/presenter/newAgentPresenter/integration.test.ts`) +- [x] Verify old UI regression: old `sessionPresenter` / `chatStore` still functional — zero impact +- [x] Manual verify: run `pnpm run dev`, create session via NewThreadPage, see streamed response + +--- + +## v1: Multi-Turn Context Assembly (complete) + +- [x] Create `contextBuilder.ts` — context assembly + truncation +- [x] Modify `processMessage` in `index.ts` — wire context builder +- [x] Unit tests for context builder (`contextBuilder.test.ts`) +- [x] Update `deepchatAgentPresenter.test.ts` — mock `getDefaultSystemPrompt`, verify multi-turn messages +- [x] Update `integration.test.ts` — verify multi-turn flow end-to-end +- [x] Quality gate: typecheck, lint, format, tests + +## v2: Tool Calling / MCP Integration (complete) + +- [x] Fetch MCP tool definitions via `ToolPresenter.getAllToolDefinitions()` and pass to `coreStream` +- [x] `tool_call_start/chunk/end` events create `tool_call` blocks in the stream +- [x] `stop_reason: 'tool_use'` triggers tool execution via `ToolPresenter.callTool()` +- [x] Tool results appended as `role: 'tool'` messages, loop re-invokes `coreStream` +- [x] Multi-turn tool loop works (multiple rounds of tool calls) +- [x] Max tool calls limit (128) stops the loop +- [x] Abort signal cancels the loop mid-execution +- [x] Tool call blocks rendered with name, params, and response +- [x] Interleaved thinking support for deepseek-reasoner / kimi-k2-thinking / glm-4.7 +- [x] Quality gate: typecheck, lint, format, tests +- [x] Fix: stop passing sessionId as conversationId to tool definitions (new agent doesn't use skills) + +## v3: Stream Processing Refactor (complete) + +- [x] Create `src/shared/utils/throttle.ts` — reusable trailing-edge throttle utility +- [x] Create `types.ts` — `StreamState`, `IoParams`, `ProcessParams`, `createState()` +- [x] Create `accumulator.ts` — pure `accumulate(state, event)` block mutations +- [x] Create `echo.ts` — interval-based flush to renderer (120ms) + DB (600ms) with throttle +- [x] Create `dispatch.ts` — `executeTools()`, `finalize()`, `finalizeError()` +- [x] Create `process.ts` — unified `processStream()` loop, single code path for tools and no-tools +- [x] Update `index.ts` — replace `handleStream`/`agentLoop` with single `processStream()` call +- [x] Delete `streamHandler.ts` and `agentLoop.ts` +- [x] Tests: `throttle.test.ts` (7), `accumulator.test.ts` (14), `echo.test.ts` (5), `dispatch.test.ts` (14), `process.test.ts` (9), updated `deepchatAgentPresenter.test.ts` (19) +- [x] Quality gate: typecheck, lint, format, 89 tests passing diff --git a/docs/specs/new-agent/v1-spec.md b/docs/specs/new-agent/v1-spec.md new file mode 100644 index 000000000..2e88f2d9a --- /dev/null +++ b/docs/specs/new-agent/v1-spec.md @@ -0,0 +1,59 @@ +# New Agent Architecture v1 — Multi-Turn Context Assembly + +## Overview + +v0 proved the new agent architecture end-to-end but sends only the latest user message to the LLM — no conversation history, no system prompt. Every message starts a fresh, context-free conversation. v1 adds multi-turn context assembly so the LLM sees the full conversation history within token limits. + +## Goals + +1. **System prompt injection** — prepend the user's configured default system prompt to every LLM call +2. **Conversation history** — include all prior sent messages in the LLM context +3. **Context window truncation** — drop oldest user+assistant pairs when history exceeds available tokens + +## Non-Goals (deferred) + +- Tool calling context (v2) +- `is_context_edge` usage (future) +- Per-session system prompts (future) +- Vision/image content in messages (future) +- Streaming context or partial messages + +## Data Model + +No new DB tables or columns. Reuses existing: +- `deepchat_messages` table — fetch prior messages by session +- `configPresenter.getDefaultSystemPrompt()` — system prompt retrieval +- `ModelConfig.contextLength` — context window size + +## Context Assembly Algorithm + +1. Fetch all messages for session via `messageStore.getMessages(sessionId)` +2. Filter to `status === 'sent'` only (skip pending/error messages) +3. Exclude the just-inserted new user message (it hasn't been marked sent yet — it's the latest with status 'sent' but we pass `newUserContent` explicitly) +4. Convert each `ChatMessageRecord` → `ChatMessage`: + - **User**: parse JSON `UserMessageContent`, extract `.text` + - **Assistant**: parse JSON `AssistantMessageBlock[]`, concatenate text from `content` and `reasoning_content` blocks +5. Apply truncation to fit within token budget +6. Prepend system prompt (if non-empty): `{ role: 'system', content }` +7. Append new user message: `{ role: 'user', content: newUserContent }` + +## Truncation Strategy + +1. Calculate: `available = contextLength - systemPromptTokens - newUserMessageTokens - reserveForOutput` +2. Sum tokens of all history messages using `approximateTokenSize` from `tokenx` +3. If total exceeds `available`, drop oldest messages from the front until it fits +4. Return trimmed history + +Reserve for output: use `maxTokens` from model config to ensure the model has room to respond. + +## Acceptance Criteria + +- [ ] Multi-turn works: LLM sees prior messages in conversation +- [ ] System prompt injected as first message when non-empty +- [ ] System prompt omitted when empty string +- [ ] Truncation drops oldest messages when history exceeds available tokens +- [ ] Error/pending messages excluded from context +- [ ] Assistant blocks concatenated correctly (content + reasoning_content) +- [ ] `pnpm run typecheck` passes +- [ ] All tests pass +- [ ] `pnpm run lint && pnpm run format` passes diff --git a/docs/specs/new-agent/v2-spec.md b/docs/specs/new-agent/v2-spec.md new file mode 100644 index 000000000..61231ff41 --- /dev/null +++ b/docs/specs/new-agent/v2-spec.md @@ -0,0 +1,132 @@ +# New Agent Architecture v2 — Tool Calling / MCP Integration + +## Status: Complete (superseded by v3 refactor) + +> The v2 implementation has been refactored into the v3 module structure. See `v3-spec.md` for the current architecture. This spec is retained for historical context on design decisions. + +## Overview + +v0 proved single-turn chat, v1 added multi-turn context assembly. The LLM currently receives `tools: []` — no tool definitions, no tool execution. v2 adds MCP tool calling so the LLM can invoke tools and receive results in an agent loop. + +## Goals + +1. **Tool definition discovery** — fetch MCP tool definitions via `ToolPresenter.getAllToolDefinitions()` and pass to `coreStream` +2. **Agent loop** — when `coreStream` stops with `stop_reason: 'tool_use'`, execute tools and re-call `coreStream` with results +3. **Tool execution** — call tools via `ToolPresenter.callTool()`, format results as `role: 'tool'` messages +4. **Tool call rendering** — emit `tool_call` blocks so the renderer displays tool invocations and results +5. **Safety limit** — cap tool calls at MAX_TOOL_CALLS per processMessage invocation + +## Non-Goals (deferred) + +- Permission pre-checking / user approval +- Question tool — halting the loop for user input +- ACP agent tool routing — ACP handles tools internally +- Search result extraction from tool responses +- MCP UI resources extraction +- Tool system prompt injection (`ToolPresenter.buildToolSystemPrompt`) + +## Data Model + +No new DB tables. Changes to existing types: + +- `AssistantBlockType` gains `'tool_call'` variant +- `AssistantMessageBlock` gains optional `tool_call` field for tool metadata +- Tool results are transient within the agent loop's `conversationMessages` array — not persisted as separate DB records (tool_call blocks in the assistant message capture the tool name, params, and response for display) + +## Architecture (current — v3 module structure) + +The v2 goals are implemented in the v3 module structure. The original `streamHandler.ts` + `agentLoop.ts` were refactored into five focused modules: + +``` +deepchatAgentPresenter/ + index.ts — session lifecycle + single processStream() call + process.ts — unified loop: stream → accumulate → echo → dispatch + accumulator.ts — accumulate(state, event): pure block mutations + echo.ts — interval-based flush to renderer + DB + dispatch.ts — executeTools() + finalize() + finalizeError() + types.ts — StreamState, IoParams, ProcessParams + contextBuilder.ts — DB records to ChatMessage[], truncation + messageStore.ts — SQLite wrapper + sessionStore.ts — SQLite wrapper +``` + +### Tool Call Flow + +``` +processMessage(sessionId, content) + ├── buildContext(...) → ChatMessage[] + ├── toolPresenter.getAllToolDefinitions(...) → MCPToolDefinition[] + └── processStream(params) + │ + ├── LOOP: + │ ├── coreStream(conversation, model, config, temp, maxTokens, tools) + │ ├── for await (event of stream): accumulate(state, event) + │ │ + │ ├── if stopReason !== 'tool_use' → BREAK + │ │ + │ ├── executeTools(state, conversation, prevBlockCount, ...) + │ │ ├── build assistant message (content + tool_calls + reasoning_content) + │ │ ├── for each tool call: + │ │ │ callTool() → push tool result to conversation → update block + │ │ └── enrich blocks with server info + │ ├── echo.flush() + │ │ + │ └── if toolCallCount > MAX_TOOL_CALLS → BREAK + │ + ├── finalize(state, io) + └── (catch) finalizeError(state, io, err) +``` + +### Stream Event Mapping + +| LLMCoreStreamEvent | Action | +|---|---| +| `tool_call_start` | Create `tool_call` block with `status: 'pending'`, record id + name | +| `tool_call_chunk` | Accumulate arguments into pending tool call | +| `tool_call_end` | Finalize arguments, move to completedToolCalls | +| `stop` with `stop_reason: 'tool_use'` | Break out of stream, enter tool execution | +| `stop` with other reason | Break out of loop, finalize | + +### Tool Call Block Format + +```typescript +{ + type: 'tool_call', + content: '', // unused for tool_call type + status: 'pending' | 'success' | 'error', + timestamp: number, + tool_call: { + id: string, + name: string, + params: string, // JSON arguments + response: string, // tool result text + server_name?: string, + server_icons?: string, + server_description?: string + } +} +``` + +## Key Dependencies + +- `IToolPresenter` — `src/shared/types/presenters/tool.presenter.d.ts` + - `getAllToolDefinitions(context)` → `MCPToolDefinition[]` + - `callTool(request: MCPToolCall)` → `{ content, rawData: MCPToolResponse }` +- `MCPToolDefinition`, `MCPToolCall`, `MCPToolResponse` — `src/shared/types/core/mcp.ts` +- `ChatMessage` with `tool_calls` and `tool_call_id` — `src/shared/types/core/chat-message.ts` +- `StopStreamEvent.stop_reason: 'tool_use'` — `src/shared/types/core/llm-events.ts` +- `ToolPresenter` already instantiated at `src/main/presenter/index.ts:191` + +## Acceptance Criteria + +- [x] Tool definitions passed to `coreStream` when tools are available +- [x] `tool_call_start/chunk/end` events create `tool_call` blocks in the stream +- [x] `stop_reason: 'tool_use'` triggers tool execution via `ToolPresenter.callTool()` +- [x] Tool results appended as `role: 'tool'` messages and loop re-invokes `coreStream` +- [x] Multi-turn tool loop works: LLM calls tools, gets results, calls more tools or produces final answer +- [x] Max tool calls limit (128) stops the loop +- [x] Abort signal cancels the loop mid-execution +- [x] Tool call blocks rendered in the UI with name, params, and response +- [x] `pnpm run typecheck` passes +- [x] All tests pass +- [x] `pnpm run lint && pnpm run format` passes diff --git a/docs/specs/new-agent/v3-spec.md b/docs/specs/new-agent/v3-spec.md new file mode 100644 index 000000000..f44bb005b --- /dev/null +++ b/docs/specs/new-agent/v3-spec.md @@ -0,0 +1,272 @@ +# New Agent Architecture v3 — Stream Processing Refactor + +## Status: Complete + +## Overview + +v2 added MCP tool calling via an agent loop. The implementation worked but had tangled responsibilities: `streamHandler.ts` (336 lines) mixed stream parsing, block accumulation, flush scheduling, and message finalization. `agentLoop.ts` shared ownership of blocks and finalization with `streamHandler`, leading to the `initialBlocks` hack and conditional `!context.initialBlocks` branching. Two code paths diverged in `index.ts` (tools vs no-tools). + +v3 refactored the stream processing into five focused pieces with clear boundaries, no shared ownership, and a single code path. + +## Goals + +1. **Separate stream accumulation from side effects** — event handling is pure block mutation, flush is independent +2. **Single loop, single code path** — no tools-vs-no-tools branching (zero tools = loop runs once) +3. **Throttled flush as a utility** — reusable throttle function, not embedded timers +4. **Each module does one thing** — accumulate, echo, dispatch, process, types +5. **No `initialBlocks` hack** — the loop owns the state, passes it down, nobody round-trips it + +## Non-Goals + +- Changing the block data model or DB schema +- Changing renderer event contracts (STREAM_EVENTS.RESPONSE/END/ERROR) +- Changing the LLMCoreStreamEvent types or provider coreStream interface +- Adding new features (permissions, retry, parallel tool exec) + +## Current Structure + +``` +deepchatAgentPresenter/ + index.ts — session lifecycle only (init, destroy, getState, cancel, getMessages) + process.ts — the loop: stream → accumulate → echo → dispatch + accumulator.ts — accumulate(state, event): mutate blocks by event type + echo.ts — start/stop throttled flush to renderer + DB + dispatch.ts — executeTools() + finalize() + finalizeError() + contextBuilder.ts — (unchanged from v1) + messageStore.ts — (unchanged) + sessionStore.ts — (unchanged) + types.ts — StreamState, IoParams, ProcessParams + +src/shared/utils/ + throttle.ts — createThrottle(fn, interval): reusable throttle utility +``` + +## Architecture + +### Shared State + +All modules operate on a single mutable `StreamState` object owned by `process.ts`: + +```typescript +// types.ts +interface ToolCallResult { + id: string + name: string + arguments: string + serverName?: string + serverIcons?: string + serverDescription?: string +} + +interface StreamState { + blocks: AssistantMessageBlock[] + metadata: MessageMetadata + startTime: number + firstTokenTime: number | null + pendingToolCalls: Map + completedToolCalls: ToolCallResult[] + stopReason: 'complete' | 'tool_use' | 'error' | 'abort' | 'max_tokens' + dirty: boolean +} + +interface IoParams { + sessionId: string + messageId: string + messageStore: DeepChatMessageStore + abortSignal: AbortSignal +} + +interface ProcessParams { + io: IoParams + coreStream: (...) => AsyncGenerator + tools: MCPToolDefinition[] + toolPresenter: IToolPresenter | null + modelId: string + modelConfig: ModelConfig + temperature: number + maxTokens: number + messages: ChatMessage[] +} +``` + +### Module Responsibilities + +#### `accumulator.ts` — event → block mutation + +Pure block mutation. No DB, no renderer, no control flow decisions. + +```typescript +function accumulate(state: StreamState, event: LLMCoreStreamEvent): void +``` + +| Event | Action | +|---|---| +| `text` | Append to current content block (coalesce consecutive) | +| `reasoning` | Append to current reasoning_content block | +| `tool_call_start` | Push new tool_call block, add to pendingToolCalls map | +| `tool_call_chunk` | Append to pending args + update block params | +| `tool_call_end` | Finalize args, move to completedToolCalls, remove from pending | +| `usage` | Set metadata fields | +| `stop` | Set stopReason | +| `error` | Push error block (status `'error'`) | + +New blocks are always created with `status: 'pending'`. The accumulator never transitions existing blocks to `'success'` or `'error'` — that's `dispatch.ts`'s job. The only exception is the `error` event handler, which creates a *new* error block with `status: 'error'`. + +Sets `state.dirty = true` on any block mutation. Sets `state.firstTokenTime` on the first `text` or `reasoning` event if not already set. + +#### `echo.ts` — interval-based flush to renderer + DB + +Two `setInterval` timers drive periodic flushing. Each interval callback checks `state.dirty` before doing work. The shared `createThrottle` utility wraps each callback to prevent overlapping flushes if a write takes longer than the interval. + +```typescript +interface EchoHandle { + flush(): void // immediate flush (after tool results) + stop(): void // clear intervals + cancel pending throttles +} + +function startEcho(state: StreamState, io: IoParams): EchoHandle +``` + +- Renderer interval: 120ms — when `state.dirty`, emit `STREAM_EVENTS.RESPONSE` with deep-cloned blocks +- DB interval: 600ms — when `state.dirty`, call `messageStore.updateAssistantContent()` +- `flush()`: immediate renderer + DB write, clears `state.dirty` +- `stop()`: clears both intervals, cancels pending throttles (called in `finally` block of process) + +#### `dispatch.ts` — tool execution + finalization + +```typescript +async function executeTools( + state: StreamState, + conversation: ChatMessage[], + prevBlockCount: number, + tools: MCPToolDefinition[], + toolPresenter: IToolPresenter, + modelId: string, + io: IoParams +): Promise + +function finalize(state: StreamState, io: IoParams): void +function finalizeError(state: StreamState, io: IoParams, error: unknown): void +``` + +Three independent functions in one module. They share no internal state — all coordination goes through `StreamState` and the arguments passed in. + +`executeTools` responsibilities: +- Use `prevBlockCount` to slice `state.blocks` and extract only the current iteration's content/reasoning/tool_call blocks +- Build assistant message from current iteration blocks (content + tool_calls) +- Include `reasoning_content` for interleaved thinking models (deepseek-reasoner, kimi-k2-thinking, glm-4.7) +- Push assistant message to conversation +- For each tool call: check `abortSignal` → call `toolPresenter.callTool()` → push tool result to conversation → update tool_call block response + status +- Enrich tool_call blocks with server info from tool definitions +- Flush to renderer + DB after each tool execution +- Returns the number of tools executed +- Does NOT enforce MAX_TOOL_CALLS (that's the loop's job in `process.ts`) + +`finalize` responsibilities: +- Mark all pending blocks as `'success'` +- Compute metadata using `state.startTime` and `state.firstTokenTime` (generationTime, firstTokenTime, tokensPerSecond) +- Call `messageStore.finalizeAssistantMessage()` +- Emit `STREAM_EVENTS.RESPONSE` (final blocks) + `STREAM_EVENTS.END` + +`finalizeError` responsibilities: +- Push error block +- Mark all pending blocks as `'error'` +- Call `messageStore.setMessageError()` +- Emit `STREAM_EVENTS.ERROR` + +#### `process.ts` — the loop + +Single entry point, single code path. No tools-vs-no-tools branching. + +```typescript +async function processStream(params: ProcessParams): Promise +``` + +``` +processStream(params) + state = createState() + conversation = [...params.messages] + echo = startEcho(state, params.io) + toolCallCount = 0 + + try { + LOOP: + prevBlockCount = state.blocks.length + stream = coreStream(conversation, ...) + reset completedToolCalls + pendingToolCalls + + for await (event of stream): + if aborted → mark blocks error, setMessageError, emit ERROR, return + accumulate(state, event) + + if aborted → break LOOP + if stopReason ≠ 'tool_use' → break LOOP + if no completedToolCalls → break LOOP + if toolCallCount + completedToolCalls > MAX_TOOL_CALLS → break LOOP + + executed = executeTools(state, conversation, prevBlockCount, ...) + toolCallCount += executed + echo.flush() + + if aborted → break LOOP + + finalize(state, params.io) + catch (err): + finalizeError(state, params.io, err) + finally: + echo.stop() +``` + +MAX_TOOL_CALLS = 128. + +#### `index.ts` — session lifecycle + +`processMessage` is thin: build context, resolve provider, construct `ProcessParams`, call `processStream`. + +``` +processMessage(sessionId, content) + ├── resolve provider + model config + ├── buildContext(...) + ├── persist user message + create assistant placeholder + ├── fetch tool definitions (no conversationId — new agent doesn't use skills) + └── processStream(params) ← single call, no branching +``` + +### Throttle Utility + +```typescript +// src/shared/utils/throttle.ts +interface ThrottledFn { + (): void // invoke (throttled — skips if called within interval of last execution) + flush(): void // invoke immediately, reset interval + cancel(): void // cancel pending invocation +} + +function createThrottle(fn: () => void, interval: number): ThrottledFn +``` + +`echo.ts` uses `createThrottle` to wrap its flush callbacks, then drives them via `setInterval`. The throttle prevents overlapping flushes — if a DB write takes >600ms, the next interval tick is a no-op instead of stacking. Available for reuse elsewhere in the codebase. + +## Migration (completed) + +This was a pure refactor. External contracts unchanged: +- `IAgentImplementation` interface: unchanged +- Renderer events (`STREAM_EVENTS.RESPONSE/END/ERROR`): unchanged +- DB schema: unchanged +- Block format (`AssistantMessageBlock`): unchanged +- `contextBuilder.ts`, `messageStore.ts`, `sessionStore.ts`: unchanged + +Files created: `throttle.ts`, `types.ts`, `accumulator.ts`, `echo.ts`, `dispatch.ts`, `process.ts` +Files deleted: `streamHandler.ts`, `agentLoop.ts` +Files modified: `index.ts` + +## Acceptance Criteria + +- [x] All existing behavior preserved (tool calling, reasoning, abort, error handling) +- [x] No `initialBlocks` or conditional caller-detection in any module +- [x] Single code path in `processMessage` (no tools-vs-no-tools branch) +- [x] `streamHandler.ts` and `agentLoop.ts` deleted +- [x] Throttle utility in `src/shared/utils/` and used by `echo.ts` +- [x] All existing tests pass (updated for new module structure) +- [x] `pnpm run typecheck` passes +- [x] `pnpm run lint && pnpm run format` passes diff --git a/docs/specs/new-ui-agent-session/spec.md b/docs/specs/new-ui-agent-session/spec.md new file mode 100644 index 000000000..168976a85 --- /dev/null +++ b/docs/specs/new-ui-agent-session/spec.md @@ -0,0 +1,28 @@ +# Agent-Aware Session Creation + +## Problem + +`NewThreadPage.onSubmit()` calls `sessionStore.createSession()` without passing `agentId`. The `createSession` action already supports `agentId` in `CreateSessionInput` and handles ACP agent mode, but it is never provided from the UI. + +## Solution + +Import `useAgentStore` in `NewThreadPage.vue` and pass the selected agent to `createSession()`. + +### Flow + +1. User selects an agent in the sidebar (sets `agentStore.selectedAgentId`) +2. User opens NewThreadPage and types a message +3. On submit, `NewThreadPage` reads `agentStore.selectedAgentId` +4. Passes `agentId` to `sessionStore.createSession()` +5. `createSession()` already handles ACP mode: + - If `agentId !== 'deepchat'`: sets `chatMode: 'acp agent'`, `acpWorkdirMap: { [agentId]: projectDir }` + - If `agentId === 'deepchat'` or undefined: standard DeepChat session + +### Key Types + +- `CreateSessionInput.agentId?: string` — from `stores/ui/session.ts` +- `agentStore.selectedAgentId: string | null` — `null` means "All Agents" filter (default to 'deepchat') + +### Files Modified + +- `src/renderer/src/pages/NewThreadPage.vue` — add `useAgentStore` import, pass `agentId` and `providerId`/`modelId` for ACP agents diff --git a/docs/specs/new-ui-agent-store/spec.md b/docs/specs/new-ui-agent-store/spec.md new file mode 100644 index 000000000..1326394b1 --- /dev/null +++ b/docs/specs/new-ui-agent-store/spec.md @@ -0,0 +1,154 @@ +# Agent Store Spec + +## Overview + +Agent Store manages the agent list (DeepChat built-in + ACP agents) and the sidebar agent filter selection. + +## File Location + +`src/renderer/src/stores/ui/agent.ts` + +## Type Definitions + +```typescript +interface UIAgent { + id: string // 'deepchat', 'claude-code-acp', 'codex-acp', custom id + name: string // Display name + type: 'deepchat' | 'builtin-acp' | 'custom-acp' + enabled: boolean +} +``` + +### Mapping from Presenter Types + +DeepChat agent is hardcoded. ACP agents come from two presenter calls: + +```typescript +// Built-in ACP agents (claude-code, codex, kimi) +const builtinAgents: AcpBuiltinAgent[] = await configPresenter.getAcpBuiltinAgents() + +// Custom ACP agents (user-defined) +const customAgents: AcpCustomAgent[] = await configPresenter.getAcpCustomAgents() +``` + +Mapping: + +```typescript +function mapBuiltinAgent(agent: AcpBuiltinAgent): UIAgent { + return { + id: agent.id, + name: agent.name, + type: 'builtin-acp', + enabled: agent.enabled + } +} + +function mapCustomAgent(agent: AcpCustomAgent): UIAgent { + return { + id: agent.id, + name: agent.name, + type: 'custom-acp', + enabled: agent.enabled + } +} +``` + +## Store Design + +```typescript +export const useAgentStore = defineStore('agent', () => { + const configPresenter = usePresenter('configPresenter') + + // --- State --- + const agents = ref([]) + const selectedAgentId = ref(null) // null = "All Agents" + const loading = ref(false) + const error = ref(null) + + // --- Getters --- + const enabledAgents = computed(() => agents.value.filter(a => a.enabled)) + const selectedAgent = computed(() => agents.value.find(a => a.id === selectedAgentId.value)) + const selectedAgentName = computed(() => selectedAgent.value?.name ?? 'All Agents') + + // --- Actions --- + async function fetchAgents(): Promise + function selectAgent(id: string | null): void + + return { + agents, selectedAgentId, loading, error, + enabledAgents, selectedAgent, selectedAgentName, + fetchAgents, selectAgent + } +}) +``` + +## Actions + +### `fetchAgents(): Promise` + +```typescript +async function fetchAgents() { + loading.value = true + error.value = null + try { + const deepchatAgent: UIAgent = { + id: 'deepchat', + name: 'DeepChat', + type: 'deepchat', + enabled: true // Always enabled + } + + const builtinAgents = await configPresenter.getAcpBuiltinAgents() + const customAgents = await configPresenter.getAcpCustomAgents() + + agents.value = [ + deepchatAgent, + ...builtinAgents.map(mapBuiltinAgent), + ...customAgents.map(mapCustomAgent) + ] + } catch (e) { + error.value = `Failed to load agents: ${e}` + } finally { + loading.value = false + } +} +``` + +### `selectAgent(id: string | null): void` + +Toggle agent filter. Passing the currently selected id deselects it (back to "All"). + +```typescript +function selectAgent(id: string | null) { + selectedAgentId.value = selectedAgentId.value === id ? null : id +} +``` + +## IPC Call Mapping + +| Action | Presenter Call | +|--------|---------------| +| Get built-in ACP agents | `configPresenter.getAcpBuiltinAgents()` | +| Get custom ACP agents | `configPresenter.getAcpCustomAgents()` | + +## Event Listeners + +| Event | Handler | +|-------|---------| +| `CONFIG_EVENTS.SETTING_CHANGED` | Re-fetch agents (ACP config may have changed) | + +Note: There are no dedicated ACP_EVENTS.AGENT_ADDED/REMOVED events in the codebase. Agent changes propagate through `CONFIG_EVENTS.SETTING_CHANGED`. + +## Error Handling + +Errors are caught in `fetchAgents` and stored in `error` ref. The DeepChat agent is always present even if ACP agent fetching fails. + +## Test Points + +1. DeepChat agent is always present in agent list +2. Built-in ACP agents are mapped correctly from `getAcpBuiltinAgents()` +3. Custom ACP agents are mapped correctly from `getAcpCustomAgents()` +4. Disabled agents appear in list but `enabledAgents` filters them out +5. `selectAgent` toggles selection (same id deselects) +6. `selectedAgentName` returns 'All Agents' when nothing selected +7. Error during fetch sets `error` and still includes DeepChat agent diff --git a/docs/specs/new-ui-chat-components/spec.md b/docs/specs/new-ui-chat-components/spec.md new file mode 100644 index 000000000..d7dc84c5c --- /dev/null +++ b/docs/specs/new-ui-chat-components/spec.md @@ -0,0 +1,214 @@ +# Chat Components Spec + +## Overview + +Chat components handle message display, input, and status configuration during active sessions. Each component's visual design must match its mock counterpart exactly. + +## Reference Files + +| Component | Mock File | +|-----------|-----------| +| ChatTopBar | `components/mock/MockTopBar.vue` | +| MessageList | `components/mock/MockMessageList.vue` | +| InputBox | `components/mock/MockInputBox.vue` | +| InputToolbar | `components/mock/MockInputToolbar.vue` | +| StatusBar | `components/mock/MockStatusBar.vue` | + +## File Locations + +``` +src/renderer/src/components/chat/ + ChatTopBar.vue + MessageList.vue + ChatInputBox.vue + ChatInputToolbar.vue + ChatStatusBar.vue +``` + +--- + +## 1. ChatTopBar + +**Mock reference**: `MockTopBar.vue` (copy layout and classes exactly) + +**Layout**: +``` +┌─────────────────────────────────────────────────────┐ +│ [folder] project-name > Session Title [Share][...]│ +└─────────────────────────────────────────────────────┘ +``` + +**Props**: +```typescript +interface Props { + title: string + project: string +} +``` + +**Data flow**: Props passed from ChatPage, which reads from `sessionStore.activeSession`. + +**Key behavior**: +- `projectName` computed as `project.split('/').pop()` +- Sticky positioning: `sticky top-0 z-10` +- Window drag region with no-drag on buttons + +--- + +## 2. MessageList + +**Mock reference**: `MockMessageList.vue` (copy layout and classes exactly) + +**Layout**: +``` +┌─────────────────────────────────────────────────────┐ +│ [User message - right-aligned] │ +│ │ +│ [Avatar] [Assistant message] │ +│ [Model name label] │ +│ [Message content] │ +└─────────────────────────────────────────────────────┘ +``` + +**Data flow**: Reads messages from the existing `useChatStore()`. + +```typescript +const chatStore = useChatStore() +const messages = computed(() => chatStore.getCurrentThreadMessages()) +``` + +**IPC call mapping**: + +| Operation | Presenter Call | +|-----------|---------------| +| Load messages | `sessionPresenter.getMessages(conversationId, page, pageSize)` | +| Load message IDs | `sessionPresenter.getMessageIds(conversationId)` | + +Note: The existing `useChatStore` already handles message fetching and caching via `STREAM_EVENTS.RESPONSE` and `STREAM_EVENTS.END`. The new MessageList component consumes from that store. + +**Key behavior**: +- User messages: right-aligned, `bg-muted rounded-2xl` +- Assistant messages: left-aligned with model icon, model name label +- Container: `max-w-3xl mx-auto px-4 py-6 space-y-6` +- Scrollable overflow + +--- + +## 3. ChatInputBox + +**Mock reference**: `MockInputBox.vue` (copy layout and classes exactly) + +**Layout**: +``` +┌─────────────────────────────────────────────────────┐ +│ Ask DeepChat anything, @ to mention files... │ +│ │ +├─────────────────────────────────────────────────────┤ +│ [toolbar slot] │ +└─────────────────────────────────────────────────────┘ +``` + +**Props/Events**: +```typescript +interface Props { + modelValue?: string + placeholder?: string +} + +interface Emits { + (e: 'update:modelValue', value: string): void + (e: 'submit', message: string): void +} +``` + +**Slots**: +- `toolbar`: Slot for InputToolbar + +**Key behavior**: +- Textarea with `min-h-[80px] resize-none border-0 bg-transparent` +- Container: `rounded-xl border bg-card/30 backdrop-blur-lg shadow-sm` +- Enter to submit (shift+enter for newline) + +--- + +## 4. ChatInputToolbar + +**Mock reference**: `MockInputToolbar.vue` (copy layout and classes exactly) + +**Layout**: +``` +┌─────────────────────────────────────────────────────┐ +│ [+] [mic] [send] │ +└─────────────────────────────────────────────────────┘ +``` + +**Events**: +```typescript +interface Emits { + (e: 'send'): void + (e: 'attach'): void +} +``` + +**Key behavior**: +- Attach button: `lucide:plus` icon, ghost variant +- Mic button: `lucide:mic` icon, ghost variant +- Send button: `lucide:arrow-up` icon, `rounded-full`, primary style + +--- + +## 5. ChatStatusBar + +**Mock reference**: `MockStatusBar.vue` (copy layout and classes exactly) + +**Layout**: +``` +┌─────────────────────────────────────────────────────┐ +│ [ModelIcon Model ▼] [Effort ▼] [Permissions ▼] │ +└─────────────────────────────────────────────────────┘ +``` + +**Data flow**: + +| Selector | Read from | Write to | +|----------|-----------|----------| +| Model | `chatStore.chatConfig.modelId` + `modelStore` for list | `sessionPresenter.updateSessionSettings()` | +| Effort (reasoningEffort) | `chatStore.chatConfig.reasoningEffort` | `sessionPresenter.updateSessionSettings()` | +| Permissions | TBD (permission system) | TBD | + +**IPC call mapping**: + +| Operation | Presenter Call | +|-----------|---------------| +| Change model | `sessionPresenter.updateSessionSettings(sessionId, { providerId, modelId })` | +| Change effort | `sessionPresenter.updateSessionSettings(sessionId, { reasoningEffort })` | +| Get model list | `configPresenter.getProviderDb()` or existing `modelStore` | + +**Key behavior**: +- All buttons: `h-6 px-2 gap-1 text-xs text-muted-foreground` +- Model selector shows ModelIcon + model name + chevron-down +- Effort selector shows gauge icon + level + chevron-down +- Permissions selector shows shield icon + level name + +--- + +## Shared Input Flow (NewThread + Chat) + +Both NewThreadPage and ChatPage use the same InputBox + InputToolbar + StatusBar combination. The submit behavior differs: + +| Context | On Submit | +|---------|-----------| +| NewThreadPage | `sessionStore.createSession({ title, message, projectDir, ... })` | +| ChatPage | `agentPresenter.chat(sessionId, message, tabId)` | + +The parent page component handles the submit event and dispatches accordingly. + +## Test Points + +1. ChatTopBar displays project name and title correctly +2. MessageList renders user messages right-aligned and assistant messages left-aligned +3. ChatInputBox emits `submit` on Enter, `update:modelValue` on typing +4. ChatInputToolbar emits `send` on send button click +5. ChatStatusBar model dropdown shows available models +6. ChatStatusBar effort dropdown shows effort levels +7. All component styles match their mock counterparts exactly diff --git a/docs/specs/new-ui-implementation/todo.md b/docs/specs/new-ui-implementation/todo.md new file mode 100644 index 000000000..f6a512e2b --- /dev/null +++ b/docs/specs/new-ui-implementation/todo.md @@ -0,0 +1,311 @@ +# New UI Implementation Development Tracker + +## Overview + +This document tracks the development progress of new UI feature implementation. All implementations must match the mock interface exactly. + +**Architecture Design**: [new-ui-implementation-plan.md](../../architecture/new-ui-implementation-plan.md) + +## Mock Reference File List + +| Mock File | Purpose | Target Replacement | +|-----------|---------|-------------------| +| `components/mock/MockWelcomePage.vue` | Welcome page | `pages/WelcomePage.vue` | +| `components/NewThreadMock.vue` | NewThread page | `pages/NewThreadPage.vue` | +| `components/mock/MockChatPage.vue` | Chat page | `pages/ChatPage.vue` | +| `components/mock/MockTopBar.vue` | Top bar | `components/chat/ChatTopBar.vue` | +| `components/mock/MockMessageList.vue` | Message list | `components/chat/MessageList.vue` | +| `components/mock/MockInputBox.vue` | Input box | `components/chat/ChatInputBox.vue` | +| `components/mock/MockInputToolbar.vue` | Input toolbar | `components/chat/ChatInputToolbar.vue` | +| `components/mock/MockStatusBar.vue` | Status bar | `components/chat/ChatStatusBar.vue` | +| `components/WindowSideBar.vue` | Sidebar | (refactored in place) | +| `composables/useMockViewState.ts` | State management | Replaced by stores | + +--- + +## Specs List + +| Spec | File | +|------|------| +| Page Router | [spec.md](../new-ui-page-state/spec.md) | +| Agent Store | [spec.md](../new-ui-agent-store/spec.md) | +| Session Store | [spec.md](../new-ui-session-store/spec.md) | +| Project Store | [spec.md](../new-ui-project-store/spec.md) | +| Sidebar Components | [spec.md](../new-ui-sidebar/spec.md) | +| Chat Components | [spec.md](../new-ui-chat-components/spec.md) | +| Page Components | [spec.md](../new-ui-pages/spec.md) | +| Agent-Aware Sessions | [spec.md](../new-ui-agent-session/spec.md) | +| Status Bar (Model/Effort) | [spec.md](../new-ui-status-bar/spec.md) | +| Markdown Rendering | [spec.md](../new-ui-markdown-rendering/spec.md) | + +--- + +## Phase 1: Store Layer + +### 1.1 Page Router Store + +- [x] Create `stores/ui/pageRouter.ts` +- [x] Define `PageRoute` type (welcome / newThread / chat) +- [x] Implement `initialize()` — check providers, check active session +- [x] Implement `goToWelcome()`, `goToNewThread()`, `goToChat(sessionId)` +- [x] Implement `currentRoute` and `chatSessionId` getters +- [x] Listen to `CONFIG_EVENTS.PROVIDER_CHANGED` +- [x] Error handling with fallback to newThread + +### 1.2 Session Store + +- [x] Create `stores/ui/session.ts` +- [x] Define `UISession` type with `resolveAgentId()` mapping +- [x] Define `mapSessionStatus()` mapping +- [x] Implement `fetchSessions()` via `sessionPresenter.getSessionList()` +- [x] Implement `createSession()` — create session + send message + navigate +- [x] Implement `selectSession()` — activate session + navigate +- [x] Implement `closeSession()` — unbind tab + navigate to newThread +- [x] Implement `groupByTime()` and `groupByProject()` grouping +- [x] Implement `getFilteredGroups(agentId)` for sidebar +- [x] Implement `toggleGroupMode()` +- [x] Listen to `CONVERSATION_EVENTS.LIST_UPDATED`, `ACTIVATED`, `DEACTIVATED` +- [x] Error handling on all async actions + +### 1.3 Agent Store + +- [x] Create `stores/ui/agent.ts` +- [x] Define `UIAgent` type +- [x] Implement `fetchAgents()` — DeepChat + `getAcpBuiltinAgents()` + `getAcpCustomAgents()` +- [x] Implement `selectAgent(id)` toggle +- [x] Implement `enabledAgents`, `selectedAgent`, `selectedAgentName` getters +- [x] Listen to `CONFIG_EVENTS.SETTING_CHANGED` +- [x] Error handling + +### 1.4 Project Store + +- [x] Create `stores/ui/project.ts` +- [x] Define `UIProject` type +- [x] Implement `deriveFromSessions(sessions)` — aggregate unique projects +- [x] Implement `selectProject(path)` +- [x] Implement `openFolderPicker()` via `devicePresenter.selectDirectory()` +- [x] Error handling + +**Acceptance Criteria**: +- [x] All stores manage state correctly +- [x] IPC calls map to correct presenter methods +- [x] Errors are caught and exposed via `error` ref +- [ ] Unit tests pass + +--- + +## Phase 2: Page Components + +### 2.1 ChatTabView Refactor + +- [x] Remove all legacy ChatView/NewThread/Mock imports +- [x] Remove `useMockViewState` usage +- [x] Route based on `pageRouter.currentRoute` +- [x] Initialize stores on mount (parallel) +- [x] Remove ArtifactDialog margin calculation (move to ChatPage if needed) + +### 2.2 WelcomePage + +- [x] Create `pages/WelcomePage.vue` +- [x] Copy exact layout/classes from `MockWelcomePage.vue` +- [x] Static provider grid (6 items) +- [x] All clicks → `windowPresenter.openOrFocusSettingsTab()` +- [x] Window drag region support + +### 2.3 NewThreadPage + +- [x] Create `pages/NewThreadPage.vue` +- [x] Copy exact layout/classes from `NewThreadMock.vue` +- [x] Project selector from `projectStore` +- [x] Integrate ChatInputBox + ChatInputToolbar +- [x] Integrate ChatStatusBar +- [x] Submit → `sessionStore.createSession()` + +### 2.4 ChatPage + +- [x] Create `pages/ChatPage.vue` +- [x] Copy exact layout/classes from `MockChatPage.vue` +- [x] Props: `sessionId` +- [x] Read session data from `sessionStore.activeSession` +- [x] Integrate ChatTopBar, MessageList, ChatInputBox, ChatInputToolbar, ChatStatusBar +- [x] Submit → `agentPresenter.sendMessage()` + +**Acceptance Criteria**: +- [x] Page layouts match mocks exactly +- [x] Page routing works correctly +- [x] No fallback to legacy ChatView + +--- + +## Phase 3: Chat Components + +### 3.1 ChatTopBar + +- [x] Create `components/chat/ChatTopBar.vue` +- [x] Copy exact layout/classes from `MockTopBar.vue` +- [x] Props: `title`, `project` +- [x] Computed: `projectName` +- [x] Share + More buttons (placeholder actions) + +### 3.2 MessageList + +- [x] Create `components/chat/MessageList.vue` +- [x] Copy exact layout/classes from `MockMessageList.vue` +- [x] Read messages via props (parent provides from store) +- [x] User message style: right-aligned, `bg-muted rounded-2xl` +- [x] Assistant message style: left with avatar + model label + +### 3.3 ChatInputBox + +- [x] Create `components/chat/ChatInputBox.vue` +- [x] Copy exact layout/classes from `MockInputBox.vue` +- [x] v-model support +- [x] Toolbar slot +- [x] Enter to submit, Shift+Enter for newline + +### 3.4 ChatInputToolbar + +- [x] Create `components/chat/ChatInputToolbar.vue` +- [x] Copy exact layout/classes from `MockInputToolbar.vue` +- [x] Attach (+), Mic, Send buttons +- [x] Emit events: `send`, `attach` + +### 3.5 ChatStatusBar + +- [x] Create `components/chat/ChatStatusBar.vue` +- [x] Copy exact layout/classes from `MockStatusBar.vue` +- [x] Model selector → placeholder with mock data (real integration deferred) +- [x] Effort selector → placeholder with mock data +- [x] Permissions selector → placeholder for now + +**Acceptance Criteria**: +- [x] All component styles match mocks exactly +- [x] Components emit correct events +- [ ] StatusBar dropdowns read/write session settings (deferred — uses placeholder data) + +--- + +## Phase 4: Sidebar Data Integration + +### 4.1 Replace Mock Data + +- [x] `mockAgents` → `agentStore.enabledAgents` +- [x] `allSessions` / `mockSessionsByDate` → `sessionStore.getFilteredGroups()` +- [x] `selectedAgentId` → `agentStore.selectedAgentId` +- [x] `groupByProject` → `sessionStore.groupMode` + +### 4.2 Replace Mock State + +- [x] Remove `useMockViewState` import +- [x] `handleNewChat` → `sessionStore.closeSession()` +- [x] `handleSessionClick` → `sessionStore.selectSession(id)` +- [x] Remove debug toggle (welcome page toggle button) + +### 4.3 Sidebar-Specific + +- [x] Keep `collapsed` as local state +- [x] `filteredGroups` computed from `sessionStore.getFilteredGroups(agentStore.selectedAgentId)` + +**Acceptance Criteria**: +- [x] Sidebar displays real session data from stores +- [x] Agent filter works end-to-end +- [x] Time/project grouping toggle works +- [x] Session click navigates to chat page + +--- + +## Phase 5: Integration and Cleanup + +### 5.1 End-to-End Flows + +- [ ] Full session creation flow: NewThread → submit → ChatPage renders with messages +- [ ] Session switching: sidebar click → ChatPage updates +- [ ] New chat: sidebar + button → NewThreadPage +- [ ] Welcome → Settings → Provider added → NewThreadPage + +### 5.2 Error Handling + +- [ ] Display `sessionStore.error` in UI +- [ ] Display `agentStore.error` in UI +- [ ] Handle presenter call failures gracefully + +### 5.3 Performance + +- [ ] Lazy load page components in ChatTabView +- [ ] Virtual scrolling for message list (if needed) + +### 5.4 Internationalization + +- [ ] Add i18n keys for all user-facing strings + +### 5.5 Cleanup + +- [ ] Delete `components/mock/MockWelcomePage.vue` +- [ ] Delete `components/mock/MockChatPage.vue` +- [ ] Delete `components/mock/MockTopBar.vue` +- [ ] Delete `components/mock/MockMessageList.vue` +- [ ] Delete `components/mock/MockInputBox.vue` +- [ ] Delete `components/mock/MockInputToolbar.vue` +- [ ] Delete `components/mock/MockStatusBar.vue` +- [ ] Delete `components/NewThreadMock.vue` +- [ ] Delete `composables/useMockViewState.ts` + +--- + +## Test Coverage + +| Module | Status | +|--------|--------| +| Page Router Store | ⬜ | +| Session Store | ⬜ | +| Agent Store | ⬜ | +| Project Store | ⬜ | +| ChatTabView routing | ⬜ | +| WelcomePage | ⬜ | +| NewThreadPage | ⬜ | +| ChatPage | ⬜ | +| ChatTopBar | ⬜ | +| MessageList | ⬜ | +| ChatInputBox | ⬜ | +| ChatStatusBar | ⬜ | +| Sidebar integration | ⬜ | + +--- + +## Phase 6: Agent-Aware Sessions, Working Settings, Markdown Rendering + +### 6.1 Agent-Aware Session Creation ([spec](../new-ui-agent-session/spec.md)) + +- [x] Import `useAgentStore` in `NewThreadPage.vue` +- [x] Pass `agentId: agentStore.selectedAgentId ?? 'deepchat'` to `sessionStore.createSession()` +- [x] Pass `providerId: 'acp'` and `modelId: agentId` when ACP agent selected +- [ ] Verify: select ACP agent → create session → session has `chatMode: 'acp agent'` + +### 6.2 Working ChatStatusBar ([spec](../new-ui-status-bar/spec.md)) + +- [x] Replace hardcoded model dropdown with `modelStore.enabledModels` data +- [x] Display current model from `chatStore.chatConfig.modelId` via `modelStore.findModelByIdOrName()` +- [x] On model select: `chatStore.updateChatConfig({ providerId, modelId })` +- [x] Replace hardcoded effort dropdown with real `reasoningEffort` / `verbosity` data +- [x] On effort select: `chatStore.updateChatConfig({ reasoningEffort: value })` +- [x] Keep permissions as read-only indicator +- [x] Add `modelStore.initialize()` to `ChatTabView.onMounted` +- [ ] Verify: model dropdown shows real enabled models +- [ ] Verify: changing model updates `chatConfig` and persists + +### 6.3 Markdown Message Rendering ([spec](../new-ui-markdown-rendering/spec.md)) + +- [x] Replace plain-text rendering with `MessageItemAssistant` / `MessageItemUser` +- [x] Remove `getUserText()`, `getAssistantText()` helpers +- [x] Remove direct `ModelIcon` and `useThemeStore` imports (handled by message components) +- [ ] Verify: messages render with full markdown, code blocks, tool calls, etc. + +--- + +## Changelog + +| Date | Update | +|------|--------| +| 2025-02-18 | v2: Rewrote all specs — page router decoupled from session, IPC mapping added, error handling, no legacy fallback | +| 2026-02-18 | v3: Phase 1–4 implemented — 4 stores, 5 chat components, 3 pages, ChatTabView refactored, sidebar integrated with stores. Typecheck/lint/format pass. Phase 5 (E2E testing, error display, i18n, mock cleanup) pending. | +| 2026-02-18 | v4: Phase 6 implemented — agent-aware session creation, working ChatStatusBar (model/effort selectors wired to chatStore + modelStore), markdown rendering via existing MessageItemAssistant/MessageItemUser components. | diff --git a/docs/specs/new-ui-markdown-rendering/spec.md b/docs/specs/new-ui-markdown-rendering/spec.md new file mode 100644 index 000000000..0d71d85c5 --- /dev/null +++ b/docs/specs/new-ui-markdown-rendering/spec.md @@ -0,0 +1,42 @@ +# Markdown Message Rendering + +## Problem + +`MessageList.vue` manually extracts text with `getAssistantText()` and renders it in a plain `
` with `whitespace-pre-wrap`. The existing codebase has a full rendering pipeline that handles markdown, code blocks, tool calls, thinking blocks, permissions, images, and more. + +## Solution + +Replace the plain-text rendering with existing message components: + +- `MessageItemAssistant.vue` — renders all assistant block types (content/markdown, reasoning, tool_call, permission, image, audio, error, plan, question) +- `MessageItemUser.vue` — renders user content with file attachments, edit support, structured content rendering + +## Component Props + +- `MessageItemAssistant`: `:message="(msg as AssistantMessage)"`, `:is-capturing-image="false"` +- `MessageItemUser`: `:message="(msg as UserMessage)"` + +Both work with `useChatStore` internally for `getActiveThreadId()`, variant maps, etc. Since `ChatPage` already activates the session in `chatStore`, these should work. + +## Type References + +From `@shared/chat`: +- `Message` — union type with `role: 'user' | 'assistant'` +- `UserMessage` — Message with `role: 'user'`, `content: UserMessageContent` +- `AssistantMessage` — Message with `role: 'assistant'`, `content: AssistantMessageBlock[]` + +## Rendering Capabilities Unlocked + +- Markdown formatting (headers, lists, bold, italic, links) +- Syntax-highlighted code blocks +- Thinking/reasoning blocks (collapsible) +- Tool call blocks with results +- Permission request blocks +- Image and audio blocks +- Error blocks +- Variant navigation +- Message toolbar (copy, retry, delete) + +## Files Modified + +- `src/renderer/src/components/chat/MessageList.vue` — rewrite to use existing message components diff --git a/docs/specs/new-ui-page-state/spec.md b/docs/specs/new-ui-page-state/spec.md new file mode 100644 index 000000000..3a3f110cb --- /dev/null +++ b/docs/specs/new-ui-page-state/spec.md @@ -0,0 +1,137 @@ +# Page Router Spec + +## Overview + +The page router controls which page is displayed in the main content area. It is a pure routing mechanism — it holds no session data, no titles, no project paths. Those belong to the Session Store. + +## File Location + +`src/renderer/src/stores/ui/pageRouter.ts` + +## Route Definitions + +```typescript +type PageRoute = + | { name: 'welcome' } + | { name: 'newThread' } + | { name: 'chat'; sessionId: string } +``` + +Three routes, each with clear entry/exit conditions: + +| Route | Condition | +|-------|-----------| +| `welcome` | No enabled providers configured | +| `newThread` | Providers exist, no active session | +| `chat` | Active session selected | + +## Store Design + +```typescript +export const usePageRouterStore = defineStore('pageRouter', () => { + const route = ref({ name: 'newThread' }) + const error = ref(null) + + // --- Actions --- + + async function initialize(): Promise + function goToWelcome(): void + function goToNewThread(): void + function goToChat(sessionId: string): void + + // --- Getters --- + + const currentRoute = computed(() => route.value.name) + const chatSessionId = computed(() => + route.value.name === 'chat' ? route.value.sessionId : null + ) + + return { route, error, initialize, goToWelcome, goToNewThread, goToChat, currentRoute, chatSessionId } +}) +``` + +## Actions + +### `initialize(): Promise` + +Called once on ChatTabView mount. Determines the initial route. + +```typescript +async function initialize() { + try { + // 1. Check if any provider is enabled + const hasProviders = await configPresenter.hasEnabledProviders() + if (!hasProviders) { + route.value = { name: 'welcome' } + return + } + + // 2. Check for active session on this tab + const tabId = window.api.getWebContentsId() + const activeSession = await sessionPresenter.getActiveSession(tabId) + if (activeSession) { + route.value = { name: 'chat', sessionId: activeSession.sessionId } + return + } + + // 3. Default to new thread + route.value = { name: 'newThread' } + } catch (e) { + error.value = String(e) + route.value = { name: 'newThread' } + } +} +``` + +### `goToWelcome(): void` + +```typescript +function goToWelcome() { + route.value = { name: 'welcome' } +} +``` + +### `goToNewThread(): void` + +```typescript +function goToNewThread() { + route.value = { name: 'newThread' } +} +``` + +### `goToChat(sessionId: string): void` + +```typescript +function goToChat(sessionId: string) { + route.value = { name: 'chat', sessionId } +} +``` + +## IPC Call Mapping + +| Action | Presenter Call | +|--------|---------------| +| Check providers | `configPresenter.hasEnabledProviders()` (or check provider list length) | +| Get active session | `sessionPresenter.getActiveSession(tabId)` | + +## Event Listeners + +| Event | Handler | +|-------|---------| +| `CONFIG_EVENTS.PROVIDER_CHANGED` | Re-check provider state; if none left → `goToWelcome()` | + +## Relationship to Other Stores + +- **Page Router does NOT read session titles, project paths, or any session detail.** That data is consumed directly by components from the Session Store. +- **Session Store calls `pageRouter.goToChat(id)`** after creating or selecting a session. +- **Session Store calls `pageRouter.goToNewThread()`** after closing a session. + +## Test Points + +1. `initialize()` routes to `welcome` when no providers exist +2. `initialize()` routes to `chat` when an active session exists on this tab +3. `initialize()` routes to `newThread` as default fallback +4. `goToChat` sets route with correct sessionId +5. `goToNewThread` clears route to newThread +6. `goToWelcome` sets route to welcome +7. Error during `initialize()` falls back to `newThread` diff --git a/docs/specs/new-ui-pages/spec.md b/docs/specs/new-ui-pages/spec.md new file mode 100644 index 000000000..c9b6f937d --- /dev/null +++ b/docs/specs/new-ui-pages/spec.md @@ -0,0 +1,272 @@ +# Page Components Spec + +## Overview + +Three page components driven by the Page Router. No fallback to old ChatView — this is a full replacement. + +## Reference Files + +| Page | Mock File | +|------|-----------| +| WelcomePage | `components/mock/MockWelcomePage.vue` | +| NewThreadPage | `components/NewThreadMock.vue` | +| ChatPage | `components/mock/MockChatPage.vue` | + +## File Locations + +``` +src/renderer/src/pages/ + WelcomePage.vue + NewThreadPage.vue + ChatPage.vue +``` + +--- + +## 1. ChatTabView.vue (Refactored) + +**File**: `src/renderer/src/views/ChatTabView.vue` + +Page entry. Routes to the correct page based on Page Router state. No legacy ChatView fallback. + +```vue + + + +``` + +**IPC calls on mount**: + +| Call | Purpose | +|------|---------| +| `pageRouter.initialize()` | Determine initial route | +| `sessionStore.fetchSessions()` | Load session list for sidebar | +| `agentStore.fetchAgents()` | Load agent list for sidebar | + +--- + +## 2. WelcomePage + +**Mock reference**: `MockWelcomePage.vue` (copy layout and classes exactly) + +**Layout**: +``` +┌─────────────────────────────────────────────────────┐ +│ [Logo 16x16] │ +│ │ +│ Welcome to DeepChat Agent │ +│ Connect a model provider to start build │ +│ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │Claude │ │OpenAI │ │DeepSeek│ │ +│ └───────┘ └───────┘ └───────┘ │ +│ ┌───────┐ ┌───────┐ ┌────────┐ │ +│ │Gemini │ │Ollama │ │OpenRouter│ │ +│ └───────┘ └───────┘ └────────┘ │ +│ │ +│ Browse all providers... │ +│ │ +│ ─────────── or connect an agent ─────────── │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ [Terminal] Set up an ACP agent │ │ +│ │ Claude Code, Codex, Kimi... │ │ +│ └─────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**Data**: Static provider list (hardcoded, matching mock). + +**IPC**: + +| Action | Presenter Call | +|--------|---------------| +| Click any provider / "Browse all" / ACP agent | `windowPresenter.openOrFocusSettingsTab(windowId)` | + +**Key classes** (from mock): +- Container: `h-full w-full flex flex-col window-drag-region` +- Content: `flex-1 flex flex-col items-center justify-center px-6` +- Logo: `w-16 h-16` +- Title: `text-3xl font-semibold text-foreground mb-2` +- Subtitle: `text-sm text-muted-foreground text-center max-w-md mb-10` +- Provider grid: `grid grid-cols-3 gap-2 w-full max-w-sm mb-4` +- Provider button: `rounded-xl border border-border/60 bg-card/40 px-3 py-4 hover:bg-accent/50` + +--- + +## 3. NewThreadPage + +**Mock reference**: `NewThreadMock.vue` (copy layout and classes exactly) + +**Layout**: +``` +┌─────────────────────────────────────────────────────┐ +│ [Logo 14x14] │ +│ │ +│ Build and explore │ +│ │ +│ [folder deepchat v] │ +│ ┌─────────────────────────────────┐ │ +│ │ Ask DeepChat anything... │ │ +│ │ │ │ +│ ├─────────────────────────────────┤ │ +│ │ [+] [mic][send] │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ [Model v] [Effort v] [Permissions v] │ +└─────────────────────────────────────────────────────┘ +``` + +**Data sources**: + +| UI Element | Store | +|------------|-------| +| Project list | `projectStore.projects` | +| Selected project | `projectStore.selectedProjectName` | +| Model/Effort | `chatStore.chatConfig` or defaults from `configPresenter` | + +**Submit flow**: + +```typescript +const handleSubmit = (message: string) => { + sessionStore.createSession({ + title: message.slice(0, 50), + message, + projectDir: projectStore.selectedProject?.path, + providerId: selectedProviderId.value, + modelId: selectedModelId.value, + reasoningEffort: selectedEffort.value + }) +} +``` + +**IPC**: + +| Action | Presenter Call | +|--------|---------------| +| Open folder | `filePresenter.selectDirectory()` (via projectStore) | +| Submit message | `sessionStore.createSession()` → `sessionPresenter.createSession()` + `agentPresenter.chat()` | + +--- + +## 4. ChatPage + +**Mock reference**: `MockChatPage.vue` (copy layout and classes exactly) + +**Props**: +```typescript +interface Props { + sessionId: string +} +``` + +**Layout**: +``` +┌─────────────────────────────────────────────────────┐ +│ ChatTopBar (sticky top) │ +├─────────────────────────────────────────────────────┤ +│ │ +│ MessageList (scroll) │ +│ │ +├─────────────────────────────────────────────────────┤ +│ ChatInputBox + ChatInputToolbar (sticky bottom) │ +│ ChatStatusBar │ +└─────────────────────────────────────────────────────┘ +``` + +**Data sources**: + +```typescript +const sessionStore = useSessionStore() +const chatStore = useChatStore() + +const session = computed(() => sessionStore.activeSession) +const title = computed(() => session.value?.title ?? 'Chat') +const project = computed(() => session.value?.projectDir ?? '') +``` + +**Submit flow**: + +```typescript +const handleSubmit = (message: string) => { + const tabId = window.api.getWebContentsId() + agentPresenter.chat(sessionStore.activeSessionId!, message, tabId) +} +``` + +**IPC**: + +| Action | Presenter Call | +|--------|---------------| +| Send message | `agentPresenter.chat(sessionId, message, tabId)` | +| Update settings | `sessionPresenter.updateSessionSettings(sessionId, settings)` | + +**Key classes** (from mock): +- Container: `h-full overflow-y-auto` +- Input area: `sticky bottom-0 z-10 px-6 pt-3 pb-3` +- Input wrapper: `flex flex-col items-center` + +--- + +## Route Transitions + +``` +┌─────────────┐ Provider configured ┌─────────────┐ +│ Welcome │ ─────────────────────────► │ NewThread │ +└─────────────┘ └──────┬──────┘ + ▲ │ + │ │ Submit message + │ All providers │ (createSession) + │ removed ▼ + │ ┌─────────────┐ + └──────────────────────────────────── │ Chat │ + closeSession └─────────────┘ +``` + +## Test Points + +1. ChatTabView renders correct page based on `pageRouter.currentRoute` +2. All stores initialize on mount +3. WelcomePage provider grid renders 6 providers +4. WelcomePage clicks open settings tab +5. NewThreadPage project selector shows projects from store +6. NewThreadPage submit creates session and navigates to chat +7. ChatPage displays title and project from active session +8. ChatPage submit sends message via agentPresenter diff --git a/docs/specs/new-ui-project-store/spec.md b/docs/specs/new-ui-project-store/spec.md new file mode 100644 index 000000000..abbd08f35 --- /dev/null +++ b/docs/specs/new-ui-project-store/spec.md @@ -0,0 +1,129 @@ +# Project Store Spec + +## Overview + +Project Store manages the recent projects list for the NewThread page project selector dropdown. Kept intentionally simple. + +## File Location + +`src/renderer/src/stores/ui/project.ts` + +## Type Definitions + +```typescript +interface UIProject { + name: string // Folder name (last segment of path) + path: string // Full path +} +``` + +## Store Design + +```typescript +export const useProjectStore = defineStore('project', () => { + const filePresenter = usePresenter('filePresenter') + + // --- State --- + const projects = ref([]) + const selectedProjectPath = ref(null) + const error = ref(null) + + // --- Getters --- + const selectedProject = computed(() => + projects.value.find(p => p.path === selectedProjectPath.value) + ) + const selectedProjectName = computed(() => + selectedProject.value?.name ?? 'Select project' + ) + + // --- Actions --- + function deriveFromSessions(sessions: UISession[]): void + function selectProject(path: string): void + async function openFolderPicker(): Promise + + return { + projects, selectedProjectPath, error, + selectedProject, selectedProjectName, + deriveFromSessions, selectProject, openFolderPicker + } +}) +``` + +## Actions + +### `deriveFromSessions(sessions: UISession[]): void` + +Extract unique project directories from the session list. Called by the Session Store after fetching sessions. + +```typescript +function deriveFromSessions(sessions: UISession[]) { + const seen = new Map() + for (const s of sessions) { + if (s.projectDir && !seen.has(s.projectDir)) { + seen.set(s.projectDir, { + name: s.projectDir.split('/').pop() ?? s.projectDir, + path: s.projectDir + }) + } + } + projects.value = Array.from(seen.values()) + + // Auto-select first project if nothing selected + if (!selectedProjectPath.value && projects.value.length > 0) { + selectedProjectPath.value = projects.value[0].path + } +} +``` + +### `selectProject(path: string): void` + +```typescript +function selectProject(path: string) { + selectedProjectPath.value = path +} +``` + +### `openFolderPicker(): Promise` + +Open native folder picker dialog to add a custom project. + +```typescript +async function openFolderPicker() { + try { + const result = await filePresenter.selectDirectory() + if (result) { + const name = result.split('/').pop() ?? result + // Add to list if not already present + if (!projects.value.some(p => p.path === result)) { + projects.value.unshift({ name, path: result }) + } + selectedProjectPath.value = result + } + } catch (e) { + error.value = `Failed to open folder picker: ${e}` + } +} +``` + +## IPC Call Mapping + +| Action | Presenter Call | +|--------|---------------| +| Open folder picker | `filePresenter.selectDirectory()` | + +## Data Flow + +``` +sessionStore.fetchSessions() + └── projectStore.deriveFromSessions(sessions) + └── projects list updated + └── NewThreadPage project dropdown reflects changes +``` + +## Test Points + +1. `deriveFromSessions` extracts unique projects from sessions +2. `deriveFromSessions` auto-selects first project when none selected +3. `selectProject` updates selectedProjectPath +4. `openFolderPicker` adds new project and selects it +5. Duplicate paths are not added diff --git a/docs/specs/new-ui-session-store/spec.md b/docs/specs/new-ui-session-store/spec.md new file mode 100644 index 000000000..d43da085d --- /dev/null +++ b/docs/specs/new-ui-session-store/spec.md @@ -0,0 +1,361 @@ +# Session Store Spec + +## Overview + +Session Store is the central owner of all session state: the session list, the active session, grouping/filtering, and session CRUD operations. It coordinates with the Page Router for navigation. + +## File Location + +`src/renderer/src/stores/ui/session.ts` + +## Type Definitions + +### UI Session (derived from presenter Session) + +The presenter returns a rich `Session` object. The UI store maps it to a flattened structure for display: + +```typescript +interface UISession { + id: string + title: string + agentId: string // Derived: see "Agent ID Resolution" below + status: UISessionStatus + projectDir: string // From session.context.agentWorkspacePath + providerId: string + modelId: string + createdAt: number + updatedAt: number +} + +type UISessionStatus = 'completed' | 'working' | 'error' | 'none' +``` + +### Agent ID Resolution + +The presenter `Session` does not have a top-level `agentId`. It is derived: + +```typescript +function resolveAgentId(session: Session): string { + // ACP agent sessions have chatMode 'acp agent' and an acpWorkdirMap + if (session.config.chatMode === 'acp agent') { + const acpMap = session.context.acpWorkdirMap + if (acpMap) { + // The first (or only) key in acpWorkdirMap is the agentId + const agentIds = Object.keys(acpMap) + if (agentIds.length > 0) return agentIds[0] + } + } + return 'deepchat' +} +``` + +### Session Status Mapping + +Map presenter `SessionStatus` to UI display status: + +```typescript +function mapSessionStatus(status: SessionStatus): UISessionStatus { + switch (status) { + case 'generating': + case 'waiting_permission': + case 'waiting_question': + return 'working' + case 'error': + return 'error' + case 'idle': + case 'paused': + return 'none' + default: + return 'none' + } +} +``` + +### Session Group + +```typescript +interface SessionGroup { + label: string // 'Today', 'Yesterday', 'Last Week', or project name + sessions: UISession[] +} + +type GroupMode = 'time' | 'project' +``` + +## Store Design + +```typescript +export const useSessionStore = defineStore('session', () => { + const sessionPresenter = usePresenter('sessionPresenter') + const pageRouter = usePageRouterStore() + + // --- State --- + const sessions = ref([]) + const activeSessionId = ref(null) + const groupMode = ref('time') + const loading = ref(false) + const error = ref(null) + + // --- Getters --- + const activeSession: ComputedRef + const sessionGroups: ComputedRef + const hasActiveSession: ComputedRef + + // --- Actions --- + async function fetchSessions(): Promise + async function createSession(params: CreateSessionInput): Promise + async function selectSession(sessionId: string): Promise + async function closeSession(): Promise + function toggleGroupMode(): void + function getFilteredGroups(agentId: string | null): SessionGroup[] + + return { + sessions, activeSessionId, groupMode, loading, error, + activeSession, sessionGroups, hasActiveSession, + fetchSessions, createSession, selectSession, closeSession, + toggleGroupMode, getFilteredGroups + } +}) +``` + +## Actions + +### `fetchSessions(): Promise` + +Load all sessions from the presenter. + +```typescript +async function fetchSessions() { + loading.value = true + error.value = null + try { + const result = await sessionPresenter.getSessionList(1, 200) + sessions.value = result.sessions.map(mapToUISession) + } catch (e) { + error.value = `Failed to load sessions: ${e}` + } finally { + loading.value = false + } +} +``` + +### `createSession(params): Promise` + +Create a new session and navigate to it. + +```typescript +interface CreateSessionInput { + title: string + message: string + projectDir?: string + providerId?: string + modelId?: string + agentId?: string // 'deepchat' or ACP agent id + reasoningEffort?: string +} + +async function createSession(params: CreateSessionInput) { + error.value = null + try { + const tabId = window.api.getWebContentsId() + const settings: Partial = {} + + if (params.providerId) settings.providerId = params.providerId + if (params.modelId) settings.modelId = params.modelId + if (params.projectDir) settings.agentWorkspacePath = params.projectDir + if (params.reasoningEffort) settings.reasoningEffort = params.reasoningEffort + + // Determine chat mode from agent + if (params.agentId && params.agentId !== 'deepchat') { + settings.chatMode = 'acp agent' + settings.acpWorkdirMap = { [params.agentId]: params.projectDir ?? null } + } + + const sessionId = await sessionPresenter.createSession({ + title: params.title || 'New Thread', + settings, + tabId + }) + + // Refresh session list and activate + await fetchSessions() + activeSessionId.value = sessionId + pageRouter.goToChat(sessionId) + + // Send the initial message + const agentPresenter = usePresenter('agentPresenter') + await agentPresenter.chat(sessionId, params.message, tabId) + } catch (e) { + error.value = `Failed to create session: ${e}` + } +} +``` + +### `selectSession(sessionId: string): Promise` + +Switch to an existing session. + +```typescript +async function selectSession(sessionId: string) { + error.value = null + try { + const tabId = window.api.getWebContentsId() + await sessionPresenter.activateSession(tabId, sessionId) + activeSessionId.value = sessionId + pageRouter.goToChat(sessionId) + } catch (e) { + error.value = `Failed to select session: ${e}` + } +} +``` + +### `closeSession(): Promise` + +Deactivate the current session and return to NewThread. + +```typescript +async function closeSession() { + error.value = null + try { + const tabId = window.api.getWebContentsId() + await sessionPresenter.unbindFromTab(tabId) + activeSessionId.value = null + pageRouter.goToNewThread() + } catch (e) { + error.value = `Failed to close session: ${e}` + } +} +``` + +### `toggleGroupMode(): void` + +```typescript +function toggleGroupMode() { + groupMode.value = groupMode.value === 'time' ? 'project' : 'time' +} +``` + +### `getFilteredGroups(agentId: string | null): SessionGroup[]` + +Returns grouped sessions, optionally filtered by agent. Used by the sidebar. + +```typescript +function getFilteredGroups(agentId: string | null): SessionGroup[] { + const grouped = groupMode.value === 'time' + ? groupByTime(sessions.value) + : groupByProject(sessions.value) + + if (agentId === null) return grouped + + return grouped + .map(group => ({ + label: group.label, + sessions: group.sessions.filter(s => s.agentId === agentId) + })) + .filter(group => group.sessions.length > 0) +} +``` + +## Getters + +```typescript +const activeSession = computed(() => + sessions.value.find(s => s.id === activeSessionId.value) +) + +const hasActiveSession = computed(() => activeSessionId.value !== null) + +const sessionGroups = computed(() => getFilteredGroups(null)) +``` + +## Grouping Logic + +### groupByTime + +```typescript +function groupByTime(sessions: UISession[]): SessionGroup[] { + const now = Date.now() + const today = startOfDay(now) + const yesterday = startOfDay(now - 86400000) + const lastWeek = startOfDay(now - 7 * 86400000) + + const groups: Record = { + 'Today': [], + 'Yesterday': [], + 'Last Week': [], + 'Older': [] + } + + for (const s of sessions) { + if (s.updatedAt >= today) groups['Today'].push(s) + else if (s.updatedAt >= yesterday) groups['Yesterday'].push(s) + else if (s.updatedAt >= lastWeek) groups['Last Week'].push(s) + else groups['Older'].push(s) + } + + return Object.entries(groups) + .filter(([, sessions]) => sessions.length > 0) + .map(([label, sessions]) => ({ label, sessions })) +} +``` + +### groupByProject + +```typescript +function groupByProject(sessions: UISession[]): SessionGroup[] { + const projectMap = new Map() + for (const session of sessions) { + const dir = session.projectDir || 'No Project' + if (!projectMap.has(dir)) projectMap.set(dir, []) + projectMap.get(dir)!.push(session) + } + return Array.from(projectMap.entries()).map(([dir, sessions]) => ({ + label: dir.split('/').pop() ?? dir, + sessions + })) +} +``` + +## IPC Call Mapping + +| Action | Presenter Call | +|--------|---------------| +| Fetch sessions | `sessionPresenter.getSessionList(page, pageSize)` | +| Create session | `sessionPresenter.createSession({ title, settings, tabId })` | +| Activate session | `sessionPresenter.activateSession(tabId, sessionId)` | +| Deactivate session | `sessionPresenter.unbindFromTab(tabId)` | +| Send message | `agentPresenter.chat(sessionId, message, tabId)` | +| Get active session | `sessionPresenter.getActiveSession(tabId)` | + +## Event Listeners + +| Event | Handler | +|-------|---------| +| `CONVERSATION_EVENTS.LIST_UPDATED` | Call `fetchSessions()` to refresh list | +| `CONVERSATION_EVENTS.ACTIVATED` | Update `activeSessionId` | +| `CONVERSATION_EVENTS.DEACTIVATED` | Clear `activeSessionId`, `goToNewThread()` | + +## Error Handling + +All async actions catch errors and set `error` ref. Components can display errors via: + +```vue +
+ {{ sessionStore.error }} +
+``` + +The `error` state is cleared at the start of each action. + +## Test Points + +1. `fetchSessions` maps presenter Sessions to UISession correctly +2. `resolveAgentId` returns 'deepchat' for agent-mode sessions +3. `resolveAgentId` returns ACP agent id for acp-agent-mode sessions +4. `createSession` creates session, refreshes list, navigates to chat +5. `selectSession` activates session and navigates to chat +6. `closeSession` unbinds tab and navigates to newThread +7. `groupByTime` correctly buckets sessions into Today/Yesterday/Last Week/Older +8. `groupByProject` correctly groups by projectDir +9. `getFilteredGroups` filters by agentId when provided +10. Error states are set on failure and cleared on retry diff --git a/docs/specs/new-ui-sidebar/spec.md b/docs/specs/new-ui-sidebar/spec.md new file mode 100644 index 000000000..5b5dc6adc --- /dev/null +++ b/docs/specs/new-ui-sidebar/spec.md @@ -0,0 +1,203 @@ +# Sidebar Component Spec + +## Overview + +The sidebar displays agent filter icons, session list with grouping, and quick action buttons. All data comes from stores — no mock data. + +## File Location + +`src/renderer/src/components/WindowSideBar.vue` + +## Visual Design (must match mock exactly) + +``` +┌─────────────────────────────────────────────────────┐ +│ Agent Icons (48px) │ Session List (240px) │ +│ ┌─────────────────┐ │ ┌───────────────────────────┐ │ +│ │ [All Agents] │ │ │ Header: Agent Name │ │ +│ │ ──────────── │ │ │ [Group] [+ New] │ │ +│ │ [DeepChat] │ │ ├───────────────────────────┤ │ +│ │ [Claude Code] │ │ │ Today │ │ +│ │ [Codex] │ │ │ - Fix login bug [v] │ │ +│ │ [Kimi] │ │ │ - Refactor auth [~] │ │ +│ │ [My Bot] │ │ ├───────────────────────────┤ │ +│ │ │ │ │ Yesterday │ │ +│ │ │ │ │ - Add dark mode │ │ +│ │ ──────────── │ │ │ - API integration [!] │ │ +│ │ [Collapse] │ │ └───────────────────────────┘ │ +│ │ [Browser] │ │ │ +│ │ [Settings] │ │ │ +│ └─────────────────┘ │ │ +└─────────────────────────────────────────────────────┘ +``` + +### Widths + +- Expanded: `w-[288px]` (48px agent column + 240px session column) +- Collapsed: `w-12` (agent column only) + +## Data Sources (all from stores) + +| UI Element | Store Source | +|------------|-------------| +| Agent icon list | `agentStore.enabledAgents` | +| Selected agent | `agentStore.selectedAgentId` | +| Agent name in header | `agentStore.selectedAgentName` | +| Session groups | `sessionStore.getFilteredGroups(agentStore.selectedAgentId)` | +| Active session | `sessionStore.activeSessionId` | +| Group mode toggle | `sessionStore.groupMode` | + +## Local State + +Only UI-specific state is local to the component: + +```typescript +const collapsed = ref(false) +``` + +Everything else comes from stores. + +## Component Implementation + +### Agent Icons Column + +```vue +
+ + + +
+ + + + +
+
+ + + + + +
+``` + +### Session List Column + +```vue +
+ +
+ {{ agentStore.selectedAgentName }} +
+ + +
+
+ + +
+ +
+ +

No conversations yet

+

Start a new chat to begin

+
+ + + +
+
+``` + +## Computed Properties + +```typescript +const filteredGroups = computed(() => + sessionStore.getFilteredGroups(agentStore.selectedAgentId) +) +``` + +## Event Handlers + +```typescript +const handleNewChat = () => { + sessionStore.closeSession() + // closeSession internally calls pageRouter.goToNewThread() +} + +const handleSessionClick = (session: UISession) => { + sessionStore.selectSession(session.id) + // selectSession internally calls pageRouter.goToChat(id) +} + +const openSettings = () => { + const windowId = window.api.getWindowId() + if (windowId != null) { + windowPresenter.openOrFocusSettingsTab(windowId) + } +} + +const onBrowserClick = async () => { + try { + await yoBrowserPresenter.show(true) + } catch (e) { + console.warn('Failed to open browser window.', e) + } +} +``` + +## Styling Reference + +All CSS classes must match the existing `WindowSideBar.vue` mock implementation exactly. Key classes: + +- Agent button selected: `bg-card/50 border-white/70 dark:border-white/20 ring-1 ring-black/10` +- Agent button default: `bg-transparent border-none hover:bg-white/30 dark:hover:bg-white/10` +- Session item active: `bg-accent text-accent-foreground` +- Session item hover: `text-foreground/80 hover:bg-accent/50` +- Status working: `text-primary animate-spin` (loader-2 icon) +- Status completed: `text-green-500` (check icon) +- Status error: `text-destructive` (alert-circle icon) +- Window drag region: `-webkit-app-region: drag` on container, `no-drag` on buttons + +## Test Points + +1. Agent icons render from `agentStore.enabledAgents` +2. Clicking agent icon calls `agentStore.selectAgent(id)` +3. Session list renders from `sessionStore.getFilteredGroups()` +4. Clicking session calls `sessionStore.selectSession(id)` +5. New Chat button calls `sessionStore.closeSession()` +6. Group toggle calls `sessionStore.toggleGroupMode()` +7. Collapse toggle hides session column +8. Empty state shows when no sessions match filter +9. Status indicators display correctly per session status diff --git a/docs/specs/new-ui-status-bar/spec.md b/docs/specs/new-ui-status-bar/spec.md new file mode 100644 index 000000000..30e69cb2c --- /dev/null +++ b/docs/specs/new-ui-status-bar/spec.md @@ -0,0 +1,39 @@ +# Working ChatStatusBar (Model + Effort Selectors) + +## Problem + +ChatStatusBar shows hardcoded "Claude 4 Sonnet" / "High" / "Default permissions". It needs to read from and write to the active session's config via `chatStore.chatConfig` and `chatStore.updateChatConfig()`. + +## Model Selector + +- **Read**: `chatStore.chatConfig.providerId` + `chatStore.chatConfig.modelId` +- **Display**: Resolve model name via `modelStore.findModelByIdOrName(modelId)` +- **List**: Flatten `modelStore.enabledModels` (array of `{ providerId, models[] }`) +- **Write**: `chatStore.updateChatConfig({ providerId, modelId })` on selection + +## Effort Selector + +- **Read**: `chatStore.chatConfig.reasoningEffort` (Anthropic/others), `chatStore.chatConfig.thinkingBudget` (Google), `chatStore.chatConfig.verbosity` (OpenAI) +- **Display**: Unified effort label from the provider-appropriate field +- **Options**: Low, Medium, High (map to `reasoningEffort` values) +- **Write**: `chatStore.updateChatConfig({ reasoningEffort: value })` — the backend normalizes per provider + +### Effort Type Reference + +From `CONVERSATION_SETTINGS`: +- `reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'` +- `verbosity?: 'low' | 'medium' | 'high'` +- `thinkingBudget?: number` + +## Permissions Indicator + +Stream-driven (permissions are requested per tool call). Kept as read-only indicator. Actual permission handling is done by `MessageBlockPermissionRequest` in the message list. + +## Initialization + +`modelStore.initialize()` must be called during `ChatTabView.onMounted` to ensure models are loaded before the status bar renders. + +## Files Modified + +- `src/renderer/src/components/chat/ChatStatusBar.vue` — major rewrite with real data +- `src/renderer/src/views/ChatTabView.vue` — add `modelStore.initialize()` to onMounted diff --git a/src/main/events.ts b/src/main/events.ts index 873d01942..26ac67fb9 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -72,6 +72,14 @@ export const STREAM_EVENTS = { PERMISSION_UPDATED: 'stream:permission-updated' // 权限状态更新,通知前端刷新UI } +// New agent session events +export const SESSION_EVENTS = { + LIST_UPDATED: 'session:list-updated', + ACTIVATED: 'session:activated', + DEACTIVATED: 'session:deactivated', + STATUS_CHANGED: 'session:status-changed' +} + // 系统相关事件 export const SYSTEM_EVENTS = { SYSTEM_THEME_UPDATED: 'system:theme-updated' diff --git a/src/main/presenter/agentPresenter/utility/utilityHandler.ts b/src/main/presenter/agentPresenter/utility/utilityHandler.ts index e107edd80..ccd2e6b51 100644 --- a/src/main/presenter/agentPresenter/utility/utilityHandler.ts +++ b/src/main/presenter/agentPresenter/utility/utilityHandler.ts @@ -265,11 +265,44 @@ export class UtilityHandler extends BaseHandler { } }) .filter((item) => item.formattedMessage.content.length > 0) - const title = await this.ctx.llmProviderPresenter.summaryTitles( - messagesWithLength.map((item) => item.formattedMessage), - conversation.settings.providerId, - conversation.settings.modelId - ) + const assistantModel = this.ctx.configPresenter.getSetting<{ + providerId: string + modelId: string + }>('assistantModel') + const fallbackProviderId = conversation.settings.providerId + const fallbackModelId = conversation.settings.modelId + const preferredProviderId = assistantModel?.providerId || fallbackProviderId + const preferredModelId = assistantModel?.modelId || fallbackModelId + + let title: string + try { + title = await this.ctx.llmProviderPresenter.summaryTitles( + messagesWithLength.map((item) => item.formattedMessage), + preferredProviderId, + preferredModelId + ) + } catch (error) { + const shouldFallback = + preferredProviderId !== fallbackProviderId || preferredModelId !== fallbackModelId + if (!shouldFallback) { + throw error + } + console.warn( + '[UtilityHandler] Failed to generate title with assistant model, fallback to conversation model', + { + preferredProviderId, + preferredModelId, + fallbackProviderId, + fallbackModelId, + error + } + ) + title = await this.ctx.llmProviderPresenter.summaryTitles( + messagesWithLength.map((item) => item.formattedMessage), + fallbackProviderId, + fallbackModelId + ) + } let cleanedTitle = title.replace(/.*?<\/think>/g, '').trim() cleanedTitle = cleanedTitle.replace(/^/, '').trim() return cleanedTitle diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index c77ea6c67..38e7fb88b 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -87,6 +87,8 @@ interface IAppSettings { skillsPath?: string // Skills directory path enableSkills?: boolean // Skills system global toggle hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings + defaultModel?: { providerId: string; modelId: string } // Default model for new conversations + defaultVisionModel?: { providerId: string; modelId: string } // Default vision model for image tools [key: string]: unknown // Allow arbitrary keys, using unknown type instead of any } @@ -1818,6 +1820,22 @@ export class ConfigPresenter implements IConfigPresenter { getConfirmoHookStatus(): { available: boolean; path: string } { return presenter.hooksNotifications.getConfirmoHookStatus() } + + getDefaultModel(): { providerId: string; modelId: string } | undefined { + return this.getSetting<{ providerId: string; modelId: string }>('defaultModel') + } + + setDefaultModel(model: { providerId: string; modelId: string } | undefined): void { + this.setSetting('defaultModel', model) + } + + getDefaultVisionModel(): { providerId: string; modelId: string } | undefined { + return this.getSetting<{ providerId: string; modelId: string }>('defaultVisionModel') + } + + setDefaultVisionModel(model: { providerId: string; modelId: string } | undefined): void { + this.setSetting('defaultVisionModel', model) + } } export { defaultShortcutKey } from './shortcutKeySettings' diff --git a/src/main/presenter/deepchatAgentPresenter/accumulator.ts b/src/main/presenter/deepchatAgentPresenter/accumulator.ts new file mode 100644 index 000000000..3f5de18a0 --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/accumulator.ts @@ -0,0 +1,137 @@ +import type { AssistantMessageBlock } from '@shared/types/agent-interface' +import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' +import type { StreamState } from './types' + +function getCurrentBlock( + blocks: AssistantMessageBlock[], + type: 'content' | 'reasoning_content' +): AssistantMessageBlock { + const last = blocks[blocks.length - 1] + if (last && last.type === type && last.status === 'pending') { + return last + } + const block: AssistantMessageBlock = { + type, + content: '', + status: 'pending', + timestamp: Date.now() + } + blocks.push(block) + return block +} + +/** + * Apply a single stream event to the accumulator state. + * Pure block mutations only — no I/O, no finalization, no emit. + */ +export function accumulate(state: StreamState, event: LLMCoreStreamEvent): void { + switch (event.type) { + case 'text': { + if (state.firstTokenTime === null) state.firstTokenTime = Date.now() + const block = getCurrentBlock(state.blocks, 'content') + block.content += event.content + state.dirty = true + break + } + case 'reasoning': { + if (state.firstTokenTime === null) state.firstTokenTime = Date.now() + const block = getCurrentBlock(state.blocks, 'reasoning_content') + block.content += event.reasoning_content + state.dirty = true + break + } + case 'tool_call_start': { + const toolBlock: AssistantMessageBlock = { + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { + id: event.tool_call_id, + name: event.tool_call_name, + params: '', + response: '' + } + } + state.blocks.push(toolBlock) + state.pendingToolCalls.set(event.tool_call_id, { + name: event.tool_call_name, + arguments: '', + blockIndex: state.blocks.length - 1 + }) + state.dirty = true + break + } + case 'tool_call_chunk': { + const pending = state.pendingToolCalls.get(event.tool_call_id) + if (pending) { + pending.arguments += event.tool_call_arguments_chunk + const block = state.blocks[pending.blockIndex] + if (block?.tool_call) { + block.tool_call.params = pending.arguments + } + state.dirty = true + } + break + } + case 'tool_call_end': { + const pending = state.pendingToolCalls.get(event.tool_call_id) + if (pending) { + const finalArgs = event.tool_call_arguments_complete ?? pending.arguments + pending.arguments = finalArgs + const block = state.blocks[pending.blockIndex] + if (block?.tool_call) { + block.tool_call.params = finalArgs + } + state.completedToolCalls.push({ + id: event.tool_call_id, + name: pending.name, + arguments: finalArgs + }) + state.pendingToolCalls.delete(event.tool_call_id) + state.dirty = true + } + break + } + case 'usage': { + state.metadata.inputTokens = event.usage.prompt_tokens + state.metadata.outputTokens = event.usage.completion_tokens + state.metadata.totalTokens = event.usage.total_tokens + break + } + case 'stop': { + state.stopReason = mapStopReason(event.stop_reason) + break + } + case 'error': { + const errorBlock: AssistantMessageBlock = { + type: 'error', + content: event.error_message, + status: 'error', + timestamp: Date.now() + } + state.blocks.push(errorBlock) + for (const block of state.blocks) { + if (block.status === 'pending') block.status = 'error' + } + state.stopReason = 'error' + state.dirty = true + break + } + default: + break + } +} + +function mapStopReason(reason: string): 'complete' | 'tool_use' | 'error' | 'abort' | 'max_tokens' { + switch (reason) { + case 'tool_use': + return 'tool_use' + case 'max_tokens': + return 'max_tokens' + case 'error': + return 'error' + default: + return 'complete' + } +} diff --git a/src/main/presenter/deepchatAgentPresenter/contextBuilder.ts b/src/main/presenter/deepchatAgentPresenter/contextBuilder.ts new file mode 100644 index 000000000..009c74d23 --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/contextBuilder.ts @@ -0,0 +1,149 @@ +import { approximateTokenSize } from 'tokenx' +import type { ChatMessage } from '@shared/types/core/chat-message' +import type { ChatMessageRecord, AssistantMessageBlock } from '@shared/types/agent-interface' +import type { DeepChatMessageStore } from './messageStore' + +/** + * Convert a ChatMessageRecord from the DB into one or more ChatMessages for the LLM. + * An assistant record with tool_call blocks expands into: + * assistant message (with text + tool_calls) + tool result messages + */ +function recordToChatMessages(record: ChatMessageRecord): ChatMessage[] { + if (record.role === 'user') { + const parsed = JSON.parse(record.content) as { text: string } + return [{ role: 'user', content: parsed.text }] + } + + // Assistant: extract text content and tool calls + const blocks = JSON.parse(record.content) as AssistantMessageBlock[] + const text = blocks + .filter((b) => b.type === 'content' || b.type === 'reasoning_content') + .map((b) => b.content) + .join('') + + const toolCallBlocks = blocks.filter((b) => b.type === 'tool_call' && b.tool_call) + + if (toolCallBlocks.length === 0) { + return [{ role: 'assistant', content: text }] + } + + // Build assistant message with tool_calls. + // Note: reasoning_content is NOT included here — for interleaved thinking + // models (DeepSeek Reasoner etc.), reasoning_content is only required on + // assistant messages in the current agent loop exchange, which the agentLoop + // handles directly. Historical messages just include reasoning in content. + const assistantMsg: ChatMessage = { + role: 'assistant', + content: text, + tool_calls: toolCallBlocks.map((b) => ({ + id: b.tool_call!.id, + type: 'function' as const, + function: { name: b.tool_call!.name, arguments: b.tool_call!.params } + })) + } + + const result: ChatMessage[] = [assistantMsg] + + // Append tool result messages + for (const b of toolCallBlocks) { + result.push({ + role: 'tool', + tool_call_id: b.tool_call!.id, + content: b.tool_call!.response || '' + }) + } + + return result +} + +/** + * Truncate history messages to fit within the available token budget. + * Drops oldest messages from the front until the total fits. + * Tool result messages (role: 'tool') are dropped together with the + * preceding assistant message that contains their tool_calls to avoid + * orphaned tool results. + */ +export function truncateContext(history: ChatMessage[], availableTokens: number): ChatMessage[] { + let total = 0 + for (const msg of history) { + total += approximateTokenSize(typeof msg.content === 'string' ? msg.content : '') + } + + if (total <= availableTokens) { + return history + } + + // Drop from the front (oldest) until we fit. + // When dropping, skip past any tool result messages that follow an + // assistant message with tool_calls so they're removed as a group. + const result = [...history] + while (result.length > 0 && total > availableTokens) { + const removed = result.shift()! + total -= approximateTokenSize(typeof removed.content === 'string' ? removed.content : '') + + // If we just removed an assistant message with tool_calls, also remove the + // subsequent tool result messages that belong to it + if (removed.role === 'assistant' && removed.tool_calls && removed.tool_calls.length > 0) { + const toolCallIds = new Set(removed.tool_calls.map((tc) => tc.id)) + while ( + result.length > 0 && + result[0].role === 'tool' && + toolCallIds.has(result[0].tool_call_id!) + ) { + const toolMsg = result.shift()! + total -= approximateTokenSize(typeof toolMsg.content === 'string' ? toolMsg.content : '') + } + } + } + + // If the result starts with orphaned tool messages (shouldn't happen after + // the above, but guard defensively), drop them + while (result.length > 0 && result[0].role === 'tool') { + const removed = result.shift()! + total -= approximateTokenSize(typeof removed.content === 'string' ? removed.content : '') + } + + return result +} + +/** + * Build the full ChatMessage[] array for an LLM call, including: + * - System prompt (if non-empty) + * - Conversation history (truncated to fit context window) + * - The new user message + */ +export function buildContext( + sessionId: string, + newUserContent: string, + systemPrompt: string, + contextLength: number, + maxTokens: number, + messageStore: DeepChatMessageStore +): ChatMessage[] { + // 1. Fetch all sent messages (excludes pending/error) + const allMessages = messageStore.getMessages(sessionId) + const sentMessages = allMessages.filter((m) => m.status === 'sent') + + // 2. Convert to ChatMessage format (tool_call records expand to multiple messages) + const history: ChatMessage[] = sentMessages.flatMap(recordToChatMessages) + + // 3. Calculate available token budget + const systemPromptTokens = systemPrompt ? approximateTokenSize(systemPrompt) : 0 + const newUserTokens = approximateTokenSize(newUserContent) + const available = contextLength - systemPromptTokens - newUserTokens - maxTokens + + // 4. Truncate history to fit + const truncatedHistory = available > 0 ? truncateContext(history, available) : [] + + // 5. Assemble final messages + const messages: ChatMessage[] = [] + + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }) + } + + messages.push(...truncatedHistory) + messages.push({ role: 'user', content: newUserContent }) + + return messages +} diff --git a/src/main/presenter/deepchatAgentPresenter/dispatch.ts b/src/main/presenter/deepchatAgentPresenter/dispatch.ts new file mode 100644 index 000000000..a54efeedb --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/dispatch.ts @@ -0,0 +1,222 @@ +import type { AssistantMessageBlock } from '@shared/types/agent-interface' +import type { ChatMessage } from '@shared/types/core/chat-message' +import type { MCPToolDefinition } from '@shared/presenter' +import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' +import type { MCPToolCall, MCPContentItem } from '@shared/types/core/mcp' +import type { StreamState, IoParams } from './types' +import { eventBus, SendTarget } from '@/eventbus' +import { STREAM_EVENTS } from '@/events' + +// ---- Private helpers ---- + +function extractTextFromBlocks(blocks: AssistantMessageBlock[]): string { + return blocks + .filter((b) => b.type === 'content') + .map((b) => b.content) + .join('') +} + +function extractReasoningFromBlocks(blocks: AssistantMessageBlock[]): string { + return blocks + .filter((b) => b.type === 'reasoning_content') + .map((b) => b.content) + .join('') +} + +function requiresReasoningField(modelId: string): boolean { + const lower = modelId.toLowerCase() + return ( + lower.includes('deepseek-reasoner') || + lower.includes('kimi-k2-thinking') || + lower.includes('glm-4.7') + ) +} + +function toolResponseToText(content: string | MCPContentItem[]): string { + if (typeof content === 'string') return content + return content + .map((item) => { + if (item.type === 'text') return item.text + if (item.type === 'resource' && item.resource?.text) return item.resource.text + return `[${item.type}]` + }) + .join('\n') +} + +function updateToolCallBlock( + blocks: AssistantMessageBlock[], + toolCallId: string, + response: string, + isError: boolean +): void { + const block = blocks.find((b) => b.type === 'tool_call' && b.tool_call?.id === toolCallId) + if (block?.tool_call) { + block.tool_call.response = response + block.status = isError ? 'error' : 'success' + } +} + +// ---- Public API ---- + +/** + * Execute completed tool calls: build the assistant message, call each tool, + * update blocks, and flush to renderer + DB after each execution. + * Returns the number of tool calls executed. + */ +export async function executeTools( + state: StreamState, + conversation: ChatMessage[], + prevBlockCount: number, + tools: MCPToolDefinition[], + toolPresenter: IToolPresenter, + modelId: string, + io: IoParams +): Promise { + // Enrich tool_call blocks with server info from tool definitions + for (const tc of state.completedToolCalls) { + const toolDef = tools.find((t) => t.function.name === tc.name) + if (toolDef) { + const block = state.blocks.find((b) => b.type === 'tool_call' && b.tool_call?.id === tc.id) + if (block?.tool_call) { + block.tool_call.server_name = toolDef.server.name + block.tool_call.server_icons = toolDef.server.icons + block.tool_call.server_description = toolDef.server.description + } + } + } + + // Build assistant message from this iteration's blocks + const iterationBlocks = state.blocks.slice(prevBlockCount) + const assistantText = extractTextFromBlocks(iterationBlocks) + const assistantMessage: ChatMessage = { + role: 'assistant', + content: assistantText, + tool_calls: state.completedToolCalls.map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.name, arguments: tc.arguments } + })) + } + + // Interleaved thinking for reasoning models + if (requiresReasoningField(modelId)) { + const reasoning = extractReasoningFromBlocks(iterationBlocks) + if (reasoning) { + assistantMessage.reasoning_content = reasoning + } + } + + conversation.push(assistantMessage) + + let executed = 0 + + // Execute each tool call + for (const tc of state.completedToolCalls) { + if (io.abortSignal.aborted) break + + const toolDef = tools.find((t) => t.function.name === tc.name) + const toolCall: MCPToolCall = { + id: tc.id, + type: 'function', + function: { name: tc.name, arguments: tc.arguments }, + server: toolDef?.server + } + + try { + const { rawData } = await toolPresenter.callTool(toolCall) + const responseText = toolResponseToText(rawData.content) + + conversation.push({ + role: 'tool', + tool_call_id: tc.id, + content: responseText + }) + + updateToolCallBlock(state.blocks, tc.id, responseText, false) + } catch (err) { + const errorText = err instanceof Error ? err.message : String(err) + + conversation.push({ + role: 'tool', + tool_call_id: tc.id, + content: `Error: ${errorText}` + }) + + updateToolCallBlock(state.blocks, tc.id, `Error: ${errorText}`, true) + } + + executed++ + + // Flush updated blocks to renderer after each tool execution + eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, { + conversationId: io.sessionId, + blocks: JSON.parse(JSON.stringify(state.blocks)) + }) + + // Persist intermediate state to DB + io.messageStore.updateAssistantContent(io.messageId, state.blocks) + } + + return executed +} + +/** + * Finalize a successful stream: mark blocks as success, compute metadata, persist. + */ +export function finalize(state: StreamState, io: IoParams): void { + for (const block of state.blocks) { + if (block.status === 'pending') block.status = 'success' + } + + const endTime = Date.now() + state.metadata.generationTime = endTime - state.startTime + if (state.firstTokenTime !== null) { + state.metadata.firstTokenTime = state.firstTokenTime - state.startTime + } + if (state.metadata.outputTokens && state.metadata.generationTime > 0) { + state.metadata.tokensPerSecond = Math.round( + (state.metadata.outputTokens / state.metadata.generationTime) * 1000 + ) + } + + io.messageStore.finalizeAssistantMessage( + io.messageId, + state.blocks, + JSON.stringify(state.metadata) + ) + eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, { + conversationId: io.sessionId, + blocks: JSON.parse(JSON.stringify(state.blocks)) + }) + eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, { + conversationId: io.sessionId + }) +} + +/** + * Finalize after an error: push error block, mark blocks as error, persist. + */ +export function finalizeError(state: StreamState, io: IoParams, error: unknown): void { + const errorMessage = error instanceof Error ? error.message : String(error) + const errorBlock: AssistantMessageBlock = { + type: 'error', + content: errorMessage, + status: 'error', + timestamp: Date.now() + } + state.blocks.push(errorBlock) + + for (const block of state.blocks) { + if (block.status === 'pending') block.status = 'error' + } + + io.messageStore.setMessageError(io.messageId, state.blocks) + eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, { + conversationId: io.sessionId, + blocks: JSON.parse(JSON.stringify(state.blocks)) + }) + eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, { + conversationId: io.sessionId, + error: errorMessage + }) +} diff --git a/src/main/presenter/deepchatAgentPresenter/echo.ts b/src/main/presenter/deepchatAgentPresenter/echo.ts new file mode 100644 index 000000000..0ecbfadf0 --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/echo.ts @@ -0,0 +1,58 @@ +import type { StreamState, IoParams } from './types' +import { createThrottle } from '@shared/utils/throttle' +import { eventBus, SendTarget } from '@/eventbus' +import { STREAM_EVENTS } from '@/events' + +const RENDERER_FLUSH_INTERVAL = 120 +const DB_FLUSH_INTERVAL = 600 + +export interface EchoHandle { + flush(): void + stop(): void +} + +export function startEcho(state: StreamState, io: IoParams): EchoHandle { + function flushToRenderer(): void { + eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, { + conversationId: io.sessionId, + blocks: JSON.parse(JSON.stringify(state.blocks)) + }) + } + + function flushToDb(): void { + try { + io.messageStore.updateAssistantContent(io.messageId, state.blocks) + } catch (err) { + console.error('Failed to flush stream content to DB:', err) + } + } + + const rendererThrottle = createThrottle(() => { + if (state.dirty) { + flushToRenderer() + } + }, RENDERER_FLUSH_INTERVAL) + + const dbThrottle = createThrottle(() => { + if (state.dirty) { + flushToDb() + } + }, DB_FLUSH_INTERVAL) + + const rendererTimer = setInterval(rendererThrottle, RENDERER_FLUSH_INTERVAL) + const dbTimer = setInterval(dbThrottle, DB_FLUSH_INTERVAL) + + return { + flush(): void { + flushToRenderer() + flushToDb() + state.dirty = false + }, + stop(): void { + clearInterval(rendererTimer) + clearInterval(dbTimer) + rendererThrottle.cancel() + dbThrottle.cancel() + } + } +} diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts new file mode 100644 index 000000000..2a4a4a529 --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -0,0 +1,246 @@ +import type { + IAgentImplementation, + DeepChatSessionState, + ChatMessageRecord, + UserMessageContent +} from '@shared/types/agent-interface' +import type { IConfigPresenter, ILlmProviderPresenter, ModelConfig } from '@shared/presenter' +import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' +import type { SQLitePresenter } from '../sqlitePresenter' +import type { ChatMessage } from '@shared/types/core/chat-message' +import { DeepChatSessionStore } from './sessionStore' +import { DeepChatMessageStore } from './messageStore' +import { processStream } from './process' +import { buildContext } from './contextBuilder' +import { eventBus, SendTarget } from '@/eventbus' +import { SESSION_EVENTS } from '@/events' + +export class DeepChatAgentPresenter implements IAgentImplementation { + private llmProviderPresenter: ILlmProviderPresenter + private configPresenter: IConfigPresenter + private toolPresenter: IToolPresenter | null + private sessionStore: DeepChatSessionStore + private messageStore: DeepChatMessageStore + private runtimeState: Map = new Map() + private abortControllers: Map = new Map() + + constructor( + llmProviderPresenter: ILlmProviderPresenter, + configPresenter: IConfigPresenter, + sqlitePresenter: SQLitePresenter, + toolPresenter?: IToolPresenter + ) { + this.llmProviderPresenter = llmProviderPresenter + this.configPresenter = configPresenter + this.toolPresenter = toolPresenter ?? null + this.sessionStore = new DeepChatSessionStore(sqlitePresenter) + this.messageStore = new DeepChatMessageStore(sqlitePresenter) + + // Crash recovery: mark any pending messages as error + const recovered = this.messageStore.recoverPendingMessages() + if (recovered > 0) { + console.log(`DeepChatAgent: recovered ${recovered} pending messages to error status`) + } + } + + async initSession( + sessionId: string, + config: { providerId: string; modelId: string } + ): Promise { + console.log( + `[DeepChatAgent] initSession id=${sessionId} provider=${config.providerId} model=${config.modelId}` + ) + this.sessionStore.create(sessionId, config.providerId, config.modelId) + this.runtimeState.set(sessionId, { + status: 'idle', + providerId: config.providerId, + modelId: config.modelId + }) + } + + async destroySession(sessionId: string): Promise { + // Cancel any in-progress generation + const controller = this.abortControllers.get(sessionId) + if (controller) { + controller.abort() + this.abortControllers.delete(sessionId) + } + + this.messageStore.deleteBySession(sessionId) + this.sessionStore.delete(sessionId) + this.runtimeState.delete(sessionId) + } + + async getSessionState(sessionId: string): Promise { + const state = this.runtimeState.get(sessionId) + if (state) return state + + // Fallback: rebuild from DB + const dbSession = this.sessionStore.get(sessionId) + if (!dbSession) return null + + const rebuilt: DeepChatSessionState = { + status: 'idle', + providerId: dbSession.provider_id, + modelId: dbSession.model_id + } + this.runtimeState.set(sessionId, rebuilt) + return rebuilt + } + + async processMessage(sessionId: string, content: string): Promise { + const state = this.runtimeState.get(sessionId) + if (!state) throw new Error(`Session ${sessionId} not found`) + + console.log( + `[DeepChatAgent] processMessage session=${sessionId} content="${content.slice(0, 60)}"` + ) + + // Update status to generating + state.status = 'generating' + eventBus.sendToRenderer(SESSION_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { + sessionId, + status: 'generating' + }) + + try { + // 1. Get provider and model config + console.log(`[DeepChatAgent] getting provider instance for "${state.providerId}"`) + const provider = ( + this.llmProviderPresenter as unknown as { + getProviderInstance: (id: string) => { + coreStream: ( + messages: ChatMessage[], + modelId: string, + modelConfig: ModelConfig, + temperature: number, + maxTokens: number, + tools: import('@shared/presenter').MCPToolDefinition[] + ) => AsyncGenerator + } + } + ).getProviderInstance(state.providerId) + + const modelConfig = this.configPresenter.getModelConfig(state.modelId, state.providerId) + const temperature = modelConfig.temperature ?? 0.7 + const maxTokens = modelConfig.maxTokens ?? 4096 + + // 2. Build messages for LLM BEFORE persisting (avoids duplicate user message) + const systemPrompt = await this.configPresenter.getDefaultSystemPrompt() + const messages = buildContext( + sessionId, + content, + systemPrompt, + modelConfig.contextLength, + maxTokens, + this.messageStore + ) + console.log( + `[DeepChatAgent] calling coreStream model=${state.modelId} temp=${temperature} maxTokens=${maxTokens} messages=${messages.length}` + ) + + // 3. Persist user message + const userOrderSeq = this.messageStore.getNextOrderSeq(sessionId) + const userContent: UserMessageContent = { + text: content, + files: [], + links: [], + search: false, + think: false + } + const userMsgId = this.messageStore.createUserMessage(sessionId, userOrderSeq, userContent) + console.log(`[DeepChatAgent] user message created id=${userMsgId} seq=${userOrderSeq}`) + + // 4. Create pending assistant message + const assistantOrderSeq = this.messageStore.getNextOrderSeq(sessionId) + const assistantMessageId = this.messageStore.createAssistantMessage( + sessionId, + assistantOrderSeq + ) + console.log( + `[DeepChatAgent] assistant message created id=${assistantMessageId} seq=${assistantOrderSeq}` + ) + + // 5. Fetch tool definitions if toolPresenter is available + const abortController = new AbortController() + this.abortControllers.set(sessionId, abortController) + + let tools: import('@shared/presenter').MCPToolDefinition[] = [] + if (this.toolPresenter) { + try { + tools = await this.toolPresenter.getAllToolDefinitions({ + chatMode: 'agent' + }) + console.log(`[DeepChatAgent] fetched ${tools.length} tool definitions`) + } catch (err) { + console.error('[DeepChatAgent] failed to fetch tool definitions:', err) + } + } + + // 6. Run unified stream processor (handles both simple and tool-calling flows) + console.log(`[DeepChatAgent] starting processStream with ${tools.length} tools`) + await processStream({ + messages, + tools, + toolPresenter: this.toolPresenter, + coreStream: provider.coreStream.bind(provider), + modelId: state.modelId, + modelConfig, + temperature, + maxTokens, + io: { + sessionId, + messageId: assistantMessageId, + messageStore: this.messageStore, + abortSignal: abortController.signal + } + }) + + // 7. Update status to idle + console.log(`[DeepChatAgent] stream completed, status → idle`) + state.status = 'idle' + this.abortControllers.delete(sessionId) + eventBus.sendToRenderer(SESSION_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { + sessionId, + status: 'idle' + }) + } catch (err) { + console.error('[DeepChatAgent] processMessage error:', err) + state.status = 'error' + this.abortControllers.delete(sessionId) + eventBus.sendToRenderer(SESSION_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { + sessionId, + status: 'error' + }) + } + } + + async cancelGeneration(sessionId: string): Promise { + const controller = this.abortControllers.get(sessionId) + if (controller) { + controller.abort() + this.abortControllers.delete(sessionId) + } + + const state = this.runtimeState.get(sessionId) + if (state) { + state.status = 'idle' + eventBus.sendToRenderer(SESSION_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { + sessionId, + status: 'idle' + }) + } + } + + async getMessages(sessionId: string): Promise { + return this.messageStore.getMessages(sessionId) + } + + async getMessageIds(sessionId: string): Promise { + return this.messageStore.getMessageIds(sessionId) + } + + async getMessage(messageId: string): Promise { + return this.messageStore.getMessage(messageId) + } +} diff --git a/src/main/presenter/deepchatAgentPresenter/messageStore.ts b/src/main/presenter/deepchatAgentPresenter/messageStore.ts new file mode 100644 index 000000000..c49b5a2f0 --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/messageStore.ts @@ -0,0 +1,115 @@ +import { nanoid } from 'nanoid' +import { SQLitePresenter } from '../sqlitePresenter' +import type { + ChatMessageRecord, + UserMessageContent, + AssistantMessageBlock +} from '@shared/types/agent-interface' + +export class DeepChatMessageStore { + private sqlitePresenter: SQLitePresenter + + constructor(sqlitePresenter: SQLitePresenter) { + this.sqlitePresenter = sqlitePresenter + } + + createUserMessage(sessionId: string, orderSeq: number, content: UserMessageContent): string { + const id = nanoid() + this.sqlitePresenter.deepchatMessagesTable.insert({ + id, + sessionId, + orderSeq, + role: 'user', + content: JSON.stringify(content), + status: 'sent' + }) + return id + } + + createAssistantMessage(sessionId: string, orderSeq: number): string { + const id = nanoid() + this.sqlitePresenter.deepchatMessagesTable.insert({ + id, + sessionId, + orderSeq, + role: 'assistant', + content: '[]', + status: 'pending' + }) + return id + } + + updateAssistantContent(messageId: string, blocks: AssistantMessageBlock[]): void { + this.sqlitePresenter.deepchatMessagesTable.updateContent(messageId, JSON.stringify(blocks)) + } + + finalizeAssistantMessage( + messageId: string, + blocks: AssistantMessageBlock[], + metadata: string + ): void { + this.sqlitePresenter.deepchatMessagesTable.updateContentAndStatus( + messageId, + JSON.stringify(blocks), + 'sent', + metadata + ) + } + + setMessageError(messageId: string, blocks: AssistantMessageBlock[]): void { + this.sqlitePresenter.deepchatMessagesTable.updateContentAndStatus( + messageId, + JSON.stringify(blocks), + 'error' + ) + } + + getMessages(sessionId: string): ChatMessageRecord[] { + const rows = this.sqlitePresenter.deepchatMessagesTable.getBySession(sessionId) + return rows.map((row) => ({ + id: row.id, + sessionId: row.session_id, + orderSeq: row.order_seq, + role: row.role, + content: row.content, + status: row.status, + isContextEdge: row.is_context_edge, + metadata: row.metadata, + createdAt: row.created_at, + updatedAt: row.updated_at + })) + } + + getMessageIds(sessionId: string): string[] { + return this.sqlitePresenter.deepchatMessagesTable.getIdsBySession(sessionId) + } + + getMessage(messageId: string): ChatMessageRecord | null { + const row = this.sqlitePresenter.deepchatMessagesTable.get(messageId) + if (!row) return null + return { + id: row.id, + sessionId: row.session_id, + orderSeq: row.order_seq, + role: row.role, + content: row.content, + status: row.status, + isContextEdge: row.is_context_edge, + metadata: row.metadata, + createdAt: row.created_at, + updatedAt: row.updated_at + } + } + + getNextOrderSeq(sessionId: string): number { + return this.sqlitePresenter.deepchatMessagesTable.getMaxOrderSeq(sessionId) + 1 + } + + deleteBySession(sessionId: string): void { + this.sqlitePresenter.deepchatMessagesTable.deleteBySession(sessionId) + } + + recoverPendingMessages(): number { + return this.sqlitePresenter.deepchatMessagesTable.recoverPendingMessages() + } +} diff --git a/src/main/presenter/deepchatAgentPresenter/process.ts b/src/main/presenter/deepchatAgentPresenter/process.ts new file mode 100644 index 000000000..2887f5613 --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/process.ts @@ -0,0 +1,115 @@ +import type { ProcessParams } from './types' +import { createState } from './types' +import { accumulate } from './accumulator' +import { startEcho } from './echo' +import { executeTools, finalize, finalizeError } from './dispatch' +import { eventBus, SendTarget } from '@/eventbus' +import { STREAM_EVENTS } from '@/events' + +const MAX_TOOL_CALLS = 128 + +/** + * Unified stream processor. Handles both simple completions and multi-turn + * tool-calling loops in a single code path. + */ +export async function processStream(params: ProcessParams): Promise { + const { + messages, + tools, + toolPresenter, + coreStream, + modelId, + modelConfig, + temperature, + maxTokens, + io + } = params + + const state = createState() + const echo = startEcho(state, io) + const conversationMessages = [...messages] + let toolCallCount = 0 + + console.log(`[ProcessStream] start session=${io.sessionId} message=${io.messageId}`) + let eventCount = 0 + + try { + while (true) { + const prevBlockCount = state.blocks.length + + const stream = coreStream( + conversationMessages, + modelId, + modelConfig, + temperature, + maxTokens, + tools + ) + + // Reset per-iteration accumulator state + state.completedToolCalls = [] + state.pendingToolCalls.clear() + + for await (const event of stream) { + eventCount++ + if (io.abortSignal.aborted) { + console.log(`[ProcessStream] aborted after ${eventCount} events`) + echo.stop() + for (const block of state.blocks) { + if (block.status === 'pending') block.status = 'error' + } + io.messageStore.setMessageError(io.messageId, state.blocks) + eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, { + conversationId: io.sessionId, + error: 'Generation cancelled' + }) + return + } + accumulate(state, event) + } + + console.log( + `[ProcessStream] stream iteration done reason=${state.stopReason} events=${eventCount} blocks=${state.blocks.length}` + ) + + // Break conditions: not tool_use, abort, no completed tool calls + if (io.abortSignal.aborted) break + if (state.stopReason !== 'tool_use') break + if (state.completedToolCalls.length === 0) break + + // Check max tool call limit + if (toolCallCount + state.completedToolCalls.length > MAX_TOOL_CALLS) { + console.log( + `[ProcessStream] max tool calls reached (${toolCallCount + state.completedToolCalls.length} > ${MAX_TOOL_CALLS}), stopping` + ) + break + } + + // Execute tools and continue loop (toolPresenter is guaranteed non-null here + // because completedToolCalls > 0 means tools were requested, which requires + // tools.length > 0, which requires toolPresenter to be non-null) + const executed = await executeTools( + state, + conversationMessages, + prevBlockCount, + tools, + toolPresenter!, + modelId, + io + ) + toolCallCount += executed + echo.flush() + + // Check abort after tool execution + if (io.abortSignal.aborted) break + } + + // Finalize + finalize(state, io) + } catch (err) { + console.error(`[ProcessStream] exception after ${eventCount} events:`, err) + finalizeError(state, io, err) + } finally { + echo.stop() + } +} diff --git a/src/main/presenter/deepchatAgentPresenter/sessionStore.ts b/src/main/presenter/deepchatAgentPresenter/sessionStore.ts new file mode 100644 index 000000000..17f0c1ebe --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/sessionStore.ts @@ -0,0 +1,21 @@ +import { SQLitePresenter } from '../sqlitePresenter' + +export class DeepChatSessionStore { + private sqlitePresenter: SQLitePresenter + + constructor(sqlitePresenter: SQLitePresenter) { + this.sqlitePresenter = sqlitePresenter + } + + create(id: string, providerId: string, modelId: string): void { + this.sqlitePresenter.deepchatSessionsTable.create(id, providerId, modelId) + } + + get(id: string) { + return this.sqlitePresenter.deepchatSessionsTable.get(id) + } + + delete(id: string): void { + this.sqlitePresenter.deepchatSessionsTable.delete(id) + } +} diff --git a/src/main/presenter/deepchatAgentPresenter/types.ts b/src/main/presenter/deepchatAgentPresenter/types.ts new file mode 100644 index 000000000..9ac01d36b --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/types.ts @@ -0,0 +1,65 @@ +import type { AssistantMessageBlock, MessageMetadata } from '@shared/types/agent-interface' +import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' +import type { ChatMessage } from '@shared/types/core/chat-message' +import type { MCPToolDefinition, ModelConfig } from '@shared/presenter' +import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' +import type { DeepChatMessageStore } from './messageStore' + +export interface ToolCallResult { + id: string + name: string + arguments: string + serverName?: string + serverIcons?: string + serverDescription?: string +} + +export interface StreamState { + blocks: AssistantMessageBlock[] + metadata: MessageMetadata + startTime: number + firstTokenTime: number | null + pendingToolCalls: Map + completedToolCalls: ToolCallResult[] + stopReason: 'complete' | 'tool_use' | 'error' | 'abort' | 'max_tokens' + dirty: boolean +} + +export interface IoParams { + sessionId: string + messageId: string + messageStore: DeepChatMessageStore + abortSignal: AbortSignal +} + +export interface ProcessParams { + messages: ChatMessage[] + tools: MCPToolDefinition[] + toolPresenter: IToolPresenter | null + coreStream: ( + messages: ChatMessage[], + modelId: string, + modelConfig: ModelConfig, + temperature: number, + maxTokens: number, + tools: MCPToolDefinition[] + ) => AsyncGenerator + modelId: string + modelConfig: ModelConfig + temperature: number + maxTokens: number + io: IoParams +} + +export function createState(): StreamState { + return { + blocks: [], + metadata: {}, + startTime: Date.now(), + firstTokenTime: null, + pendingToolCalls: new Map(), + completedToolCalls: [], + stopReason: 'complete', + dirty: false + } +} diff --git a/src/main/presenter/deeplinkPresenter/index.ts b/src/main/presenter/deeplinkPresenter/index.ts index 3c4f50ba0..cbf96cefb 100644 --- a/src/main/presenter/deeplinkPresenter/index.ts +++ b/src/main/presenter/deeplinkPresenter/index.ts @@ -293,48 +293,13 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { await tabPresenter.switchTab(chatTab.id) await new Promise((resolve) => setTimeout(resolve, 100)) } - } else { - const newTabId = await tabPresenter.createTab(windowId, 'local://chat', { active: true }) - if (newTabId) { - console.log(`[Deeplink] Waiting for tab ${newTabId} renderer to be ready`) - await this.waitForTabReady(newTabId) - } } + // Shell windows no longer create chat tabs } catch (error) { console.error('Error ensuring chat tab active:', error) } } - /** - * 等待标签页渲染进程准备就绪 - * @param tabId 标签页ID - */ - private async waitForTabReady(tabId: number): Promise { - return new Promise((resolve) => { - let resolved = false - const onTabReady = (readyTabId: number) => { - if (readyTabId === tabId && !resolved) { - resolved = true - console.log(`[Deeplink] Tab ${tabId} renderer is ready`) - eventBus.off('tab:renderer-ready', onTabReady) - clearTimeout(timeoutId) - resolve() - } - } - - eventBus.on('tab:renderer-ready', onTabReady) - - const timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true - eventBus.off('tab:renderer-ready', onTabReady) - console.log(`[Deeplink] Timeout waiting for tab ${tabId}, proceeding anyway`) - resolve() - } - }, 3000) - }) - } - async handleMcpInstall(params: URLSearchParams): Promise { console.log('Processing mcp/install command, parameters:', Object.fromEntries(params.entries())) diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 782db9235..7516ec851 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -28,7 +28,9 @@ import { IToolPresenter, IYoBrowserPresenter, ISkillPresenter, - ISkillSyncPresenter + ISkillSyncPresenter, + INewAgentPresenter, + IProjectPresenter } from '@shared/presenter' import { eventBus } from '@/eventbus' import { LLMProviderPresenter } from './llmProviderPresenter' @@ -62,6 +64,9 @@ import { ConversationExporterService } from './exporter' import { SkillPresenter } from './skillPresenter' import { SkillSyncPresenter } from './skillSyncPresenter' import { HooksNotificationsService } from './hooksNotifications' +import { NewAgentPresenter } from './newAgentPresenter' +import { DeepChatAgentPresenter } from './deepchatAgentPresenter' +import { ProjectPresenter } from './projectPresenter' // IPC调用上下文接口 interface IPCCallContext { @@ -110,6 +115,8 @@ export class Presenter implements IPresenter { lifecycleManager: ILifecycleManager skillPresenter: ISkillPresenter skillSyncPresenter: ISkillSyncPresenter + newAgentPresenter: INewAgentPresenter + projectPresenter: IProjectPresenter hooksNotifications: HooksNotificationsService filePermissionService: FilePermissionService settingsPermissionService: SettingsPermissionService @@ -194,6 +201,23 @@ export class Presenter implements IPresenter { // Initialize Skill Sync presenter this.skillSyncPresenter = new SkillSyncPresenter(this.skillPresenter, this.configPresenter) + // Initialize new agent architecture presenters + const deepchatAgentPresenter = new DeepChatAgentPresenter( + this.llmproviderPresenter as unknown as ILlmProviderPresenter, + this.configPresenter, + this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter, + this.toolPresenter + ) + this.newAgentPresenter = new NewAgentPresenter( + deepchatAgentPresenter, + this.configPresenter, + this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter + ) + this.projectPresenter = new ProjectPresenter( + this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter, + this.devicePresenter + ) + // Initialize Hooks & Notifications service this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { sessionPresenter: this.sessionPresenter, diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts index acffaf8cf..8ba11bfb3 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts @@ -31,7 +31,7 @@ export function getInMemoryServer( case 'deepResearch': return new DeepResearchServer(env) case 'imageServer': - return new ImageServer(args[0], args[1]) + return new ImageServer(args[0] || undefined, args[1] || undefined) case 'powerpack': return new PowerpackServer(env) case 'difyKnowledge': diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts index aaee81edb..370acb6eb 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts @@ -49,9 +49,10 @@ export class ImageServer { private provider: string private model: string - constructor(provider: string, model: string) { - this.provider = provider - this.model = model + constructor(provider?: string, model?: string) { + const defaultVisionModel = presenter.configPresenter.getDefaultVisionModel() + this.provider = provider || defaultVisionModel?.providerId || 'openai' + this.model = model || defaultVisionModel?.modelId || 'gpt-4o' this.server = new Server( { name: 'image-processing-server', @@ -71,6 +72,21 @@ export class ImageServer { // // Initialization logic, e.g., configure upload service client // } + private getEffectiveModel(): { provider: string; model: string } { + if (this.provider && this.model) { + return { provider: this.provider, model: this.model } + } + + const defaultVisionModel = presenter.configPresenter.getDefaultVisionModel() + if (defaultVisionModel?.providerId && defaultVisionModel?.modelId) { + return { provider: defaultVisionModel.providerId, model: defaultVisionModel.modelId } + } + + throw new Error( + 'No vision model configured. Please set a default vision model in Settings > Common > Default Model.' + ) + } + public startServer(transport: Transport): void { this.server.connect(transport) } @@ -94,9 +110,10 @@ export class ImageServer { fileBuffer: Buffer, prompt: string ): Promise { + const { provider, model } = this.getEffectiveModel() // TODO: Implement actual API call to a multimodal model (e.g., GPT-4o, Gemini) console.log( - `Querying ${filePath} (size: ${fileBuffer.length} bytes) using ${this.provider}/${this.model} with prompt: "${prompt}"...` + `Querying ${filePath} (size: ${fileBuffer.length} bytes) using ${provider}/${model} with prompt: "${prompt}"...` ) // Construct the messages array for the multimodal model @@ -117,13 +134,13 @@ export class ImageServer { } ] - const modelConfig = presenter.configPresenter.getModelConfig(this.model, this.provider) + const modelConfig = presenter.configPresenter.getModelConfig(model, provider) try { const response = await presenter.llmproviderPresenter.generateCompletionStandalone( - this.provider, + provider, messages, - this.model, + model, modelConfig?.temperature ?? 0.6, modelConfig?.maxTokens || 1000 ) @@ -139,9 +156,10 @@ export class ImageServer { } private async ocrImageWithModel(filePath: string, fileBuffer: Buffer): Promise { + const { provider, model } = this.getEffectiveModel() // TODO: Implement actual API call to an OCR service or a multimodal model capable of OCR console.log( - `Requesting OCR for ${filePath} (size: ${fileBuffer.length} bytes) using ${this.provider}/${this.model}...` + `Requesting OCR for ${filePath} (size: ${fileBuffer.length} bytes) using ${provider}/${model}...` ) // Construct the messages array for the multimodal model @@ -164,13 +182,13 @@ export class ImageServer { console.log(messages) - const modelConfig = presenter.configPresenter.getModelConfig(this.model) + const modelConfig = presenter.configPresenter.getModelConfig(model, provider) try { const ocrText = await presenter.llmproviderPresenter.generateCompletionStandalone( - this.provider, + provider, messages, - this.model, + model, modelConfig?.temperature ?? 0.6, modelConfig?.maxTokens || 1000 ) diff --git a/src/main/presenter/newAgentPresenter/agentRegistry.ts b/src/main/presenter/newAgentPresenter/agentRegistry.ts new file mode 100644 index 000000000..53f56d836 --- /dev/null +++ b/src/main/presenter/newAgentPresenter/agentRegistry.ts @@ -0,0 +1,23 @@ +import type { IAgentImplementation, Agent } from '@shared/types/agent-interface' + +export class AgentRegistry { + private agents: Map = new Map() + + register(meta: Agent, implementation: IAgentImplementation): void { + this.agents.set(meta.id, { meta, impl: implementation }) + } + + resolve(agentId: string): IAgentImplementation { + const entry = this.agents.get(agentId) + if (!entry) throw new Error(`Agent not found: ${agentId}`) + return entry.impl + } + + getAll(): Agent[] { + return Array.from(this.agents.values()).map((e) => e.meta) + } + + has(agentId: string): boolean { + return this.agents.has(agentId) + } +} diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts new file mode 100644 index 000000000..876bd8041 --- /dev/null +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -0,0 +1,189 @@ +import type { + Agent, + CreateSessionInput, + SessionWithState, + ChatMessageRecord +} from '@shared/types/agent-interface' +import type { IConfigPresenter } from '@shared/presenter' +import type { SQLitePresenter } from '../sqlitePresenter' +import type { DeepChatAgentPresenter } from '../deepchatAgentPresenter' +import { AgentRegistry } from './agentRegistry' +import { NewSessionManager } from './sessionManager' +import { NewMessageManager } from './messageManager' +import { eventBus, SendTarget } from '@/eventbus' +import { SESSION_EVENTS } from '@/events' + +export class NewAgentPresenter { + private agentRegistry: AgentRegistry + private sessionManager: NewSessionManager + private messageManager: NewMessageManager + private configPresenter: IConfigPresenter + + constructor( + deepchatAgent: DeepChatAgentPresenter, + configPresenter: IConfigPresenter, + sqlitePresenter: SQLitePresenter + ) { + this.configPresenter = configPresenter + this.agentRegistry = new AgentRegistry() + this.sessionManager = new NewSessionManager(sqlitePresenter) + this.messageManager = new NewMessageManager(this.agentRegistry, this.sessionManager) + + // Register the built-in deepchat agent + this.agentRegistry.register( + { id: 'deepchat', name: 'DeepChat', type: 'deepchat', enabled: true }, + deepchatAgent + ) + } + + // ---- IPC-facing methods ---- + + async createSession(input: CreateSessionInput, webContentsId: number): Promise { + const agentId = input.agentId || 'deepchat' + console.log(`[NewAgentPresenter] createSession agent=${agentId} webContentsId=${webContentsId}`) + + const agent = this.agentRegistry.resolve(agentId) + + // Resolve provider/model + const defaultModel = this.configPresenter.getDefaultModel() + const providerId = input.providerId ?? defaultModel?.providerId ?? '' + const modelId = input.modelId ?? defaultModel?.modelId ?? '' + console.log(`[NewAgentPresenter] resolved provider=${providerId} model=${modelId}`) + + if (!providerId || !modelId) { + throw new Error('No provider or model configured. Please set a default model in settings.') + } + + // Create session record + const title = input.message.slice(0, 50) || 'New Chat' + const sessionId = this.sessionManager.create(agentId, title, input.projectDir ?? null) + console.log(`[NewAgentPresenter] session created id=${sessionId} title="${title}"`) + + // Initialize agent-side session + await agent.initSession(sessionId, { providerId, modelId }) + console.log(`[NewAgentPresenter] agent.initSession done`) + + // Bind to window and emit activated + this.sessionManager.bindWindow(webContentsId, sessionId) + eventBus.sendToRenderer(SESSION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + webContentsId, + sessionId + }) + eventBus.sendToRenderer(SESSION_EVENTS.LIST_UPDATED, SendTarget.ALL_WINDOWS) + + // Process the first message (non-blocking) + console.log(`[NewAgentPresenter] firing processMessage (non-blocking)`) + agent.processMessage(sessionId, input.message).catch((err) => { + console.error('[NewAgentPresenter] processMessage failed:', err) + }) + + // Return enriched session + const state = await agent.getSessionState(sessionId) + return { + id: sessionId, + agentId, + title, + projectDir: input.projectDir ?? null, + isPinned: false, + createdAt: Date.now(), + updatedAt: Date.now(), + status: state?.status ?? 'idle', + providerId: state?.providerId ?? providerId, + modelId: state?.modelId ?? modelId + } + } + + async sendMessage(sessionId: string, content: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) throw new Error(`Session not found: ${sessionId}`) + const agent = this.agentRegistry.resolve(session.agentId) + await agent.processMessage(sessionId, content) + } + + async getSessionList(filters?: { + agentId?: string + projectDir?: string + }): Promise { + const records = this.sessionManager.list(filters) + const enriched: SessionWithState[] = [] + + for (const record of records) { + const agent = this.agentRegistry.resolve(record.agentId) + const state = await agent.getSessionState(record.id) + enriched.push({ + ...record, + status: state?.status ?? 'idle', + providerId: state?.providerId ?? '', + modelId: state?.modelId ?? '' + }) + } + + return enriched + } + + async getSession(sessionId: string): Promise { + const record = this.sessionManager.get(sessionId) + if (!record) return null + const agent = this.agentRegistry.resolve(record.agentId) + const state = await agent.getSessionState(sessionId) + return { + ...record, + status: state?.status ?? 'idle', + providerId: state?.providerId ?? '', + modelId: state?.modelId ?? '' + } + } + + async getMessages(sessionId: string): Promise { + return this.messageManager.getMessages(sessionId) + } + + async getMessageIds(sessionId: string): Promise { + return this.messageManager.getMessageIds(sessionId) + } + + async getMessage(messageId: string): Promise { + return this.messageManager.getMessage(messageId) + } + + async activateSession(webContentsId: number, sessionId: string): Promise { + this.sessionManager.bindWindow(webContentsId, sessionId) + eventBus.sendToRenderer(SESSION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + webContentsId, + sessionId + }) + } + + async deactivateSession(webContentsId: number): Promise { + this.sessionManager.unbindWindow(webContentsId) + eventBus.sendToRenderer(SESSION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { + webContentsId + }) + } + + async getActiveSession(webContentsId: number): Promise { + const sessionId = this.sessionManager.getActiveSessionId(webContentsId) + if (!sessionId) return null + return this.getSession(sessionId) + } + + async getAgents(): Promise { + return this.agentRegistry.getAll() + } + + async deleteSession(sessionId: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) return + const agent = this.agentRegistry.resolve(session.agentId) + await agent.destroySession(sessionId) + this.sessionManager.delete(sessionId) + eventBus.sendToRenderer(SESSION_EVENTS.LIST_UPDATED, SendTarget.ALL_WINDOWS) + } + + async cancelGeneration(sessionId: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) return + const agent = this.agentRegistry.resolve(session.agentId) + await agent.cancelGeneration(sessionId) + } +} diff --git a/src/main/presenter/newAgentPresenter/messageManager.ts b/src/main/presenter/newAgentPresenter/messageManager.ts new file mode 100644 index 000000000..8339a10cb --- /dev/null +++ b/src/main/presenter/newAgentPresenter/messageManager.ts @@ -0,0 +1,39 @@ +import type { ChatMessageRecord } from '@shared/types/agent-interface' +import type { AgentRegistry } from './agentRegistry' +import type { NewSessionManager } from './sessionManager' + +export class NewMessageManager { + private agentRegistry: AgentRegistry + private sessionManager: NewSessionManager + + constructor(agentRegistry: AgentRegistry, sessionManager: NewSessionManager) { + this.agentRegistry = agentRegistry + this.sessionManager = sessionManager + } + + async getMessages(sessionId: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) throw new Error(`Session not found: ${sessionId}`) + const agent = this.agentRegistry.resolve(session.agentId) + return agent.getMessages(sessionId) + } + + async getMessageIds(sessionId: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) throw new Error(`Session not found: ${sessionId}`) + const agent = this.agentRegistry.resolve(session.agentId) + return agent.getMessageIds(sessionId) + } + + async getMessage(messageId: string): Promise { + // For getMessage, we need to find which agent owns this message. + // In v0, there's only deepchat, so we resolve directly. + const agents = this.agentRegistry.getAll() + for (const agentMeta of agents) { + const agent = this.agentRegistry.resolve(agentMeta.id) + const msg = await agent.getMessage(messageId) + if (msg) return msg + } + return null + } +} diff --git a/src/main/presenter/newAgentPresenter/sessionManager.ts b/src/main/presenter/newAgentPresenter/sessionManager.ts new file mode 100644 index 000000000..f86810fe3 --- /dev/null +++ b/src/main/presenter/newAgentPresenter/sessionManager.ts @@ -0,0 +1,78 @@ +import { nanoid } from 'nanoid' +import type { SQLitePresenter } from '../sqlitePresenter' +import type { SessionRecord } from '@shared/types/agent-interface' + +export class NewSessionManager { + private sqlitePresenter: SQLitePresenter + // webContentsId → sessionId + private windowBindings: Map = new Map() + + constructor(sqlitePresenter: SQLitePresenter) { + this.sqlitePresenter = sqlitePresenter + } + + create(agentId: string, title: string, projectDir: string | null): string { + const id = nanoid() + this.sqlitePresenter.newSessionsTable.create(id, agentId, title, projectDir) + return id + } + + get(id: string): SessionRecord | null { + const row = this.sqlitePresenter.newSessionsTable.get(id) + if (!row) return null + return { + id: row.id, + agentId: row.agent_id, + title: row.title, + projectDir: row.project_dir, + isPinned: row.is_pinned === 1, + createdAt: row.created_at, + updatedAt: row.updated_at + } + } + + list(filters?: { agentId?: string; projectDir?: string }): SessionRecord[] { + const rows = this.sqlitePresenter.newSessionsTable.list(filters) + return rows.map((row) => ({ + id: row.id, + agentId: row.agent_id, + title: row.title, + projectDir: row.project_dir, + isPinned: row.is_pinned === 1, + createdAt: row.created_at, + updatedAt: row.updated_at + })) + } + + update( + id: string, + fields: Partial> + ): void { + const dbFields: { title?: string; project_dir?: string | null; is_pinned?: number } = {} + if (fields.title !== undefined) dbFields.title = fields.title + if (fields.projectDir !== undefined) dbFields.project_dir = fields.projectDir + if (fields.isPinned !== undefined) dbFields.is_pinned = fields.isPinned ? 1 : 0 + this.sqlitePresenter.newSessionsTable.update(id, dbFields) + } + + delete(id: string): void { + this.sqlitePresenter.newSessionsTable.delete(id) + } + + // Window binding management + bindWindow(webContentsId: number, sessionId: string): void { + this.windowBindings.set(webContentsId, sessionId) + } + + unbindWindow(webContentsId: number): void { + this.windowBindings.set(webContentsId, null) + } + + getActiveSessionId(webContentsId: number): string | null { + return this.windowBindings.get(webContentsId) ?? null + } + + generateId(): string { + return nanoid() + } +} diff --git a/src/main/presenter/projectPresenter/index.ts b/src/main/presenter/projectPresenter/index.ts new file mode 100644 index 000000000..31e576fcb --- /dev/null +++ b/src/main/presenter/projectPresenter/index.ts @@ -0,0 +1,45 @@ +import path from 'path' +import type { IDevicePresenter } from '@shared/presenter' +import type { SQLitePresenter } from '../sqlitePresenter' +import type { Project } from '@shared/types/agent-interface' + +export class ProjectPresenter { + private sqlitePresenter: SQLitePresenter + private devicePresenter: IDevicePresenter + + constructor(sqlitePresenter: SQLitePresenter, devicePresenter: IDevicePresenter) { + this.sqlitePresenter = sqlitePresenter + this.devicePresenter = devicePresenter + } + + async getProjects(): Promise { + const rows = this.sqlitePresenter.newProjectsTable.getAll() + return rows.map((row) => ({ + path: row.path, + name: row.name, + icon: row.icon, + lastAccessedAt: row.last_accessed_at + })) + } + + async getRecentProjects(limit: number = 10): Promise { + const rows = this.sqlitePresenter.newProjectsTable.getRecent(limit) + return rows.map((row) => ({ + path: row.path, + name: row.name, + icon: row.icon, + lastAccessedAt: row.last_accessed_at + })) + } + + async selectDirectory(): Promise { + const result = await this.devicePresenter.selectDirectory() + if (result.canceled || result.filePaths.length === 0) return null + + const dirPath = result.filePaths[0] + const dirName = path.basename(dirPath) + + this.sqlitePresenter.newProjectsTable.upsert(dirPath, dirName) + return dirPath + } +} diff --git a/src/main/presenter/sessionPresenter/index.ts b/src/main/presenter/sessionPresenter/index.ts index 73d1e2e42..b43b4934a 100644 --- a/src/main/presenter/sessionPresenter/index.ts +++ b/src/main/presenter/sessionPresenter/index.ts @@ -33,6 +33,7 @@ export class SessionPresenter implements ISessionPresenter { private sqlitePresenter: ISQLitePresenter private messageManager: MessageManager private llmProviderPresenter: ILlmProviderPresenter + private configPresenter: IConfigPresenter private conversationManager: ConversationManager private exporter: IConversationExporter private commandPermissionService: CommandPermissionService @@ -49,6 +50,7 @@ export class SessionPresenter implements ISessionPresenter { this.sqlitePresenter = options.sqlitePresenter this.messageManager = options.messageManager ?? new MessageManager(options.sqlitePresenter) this.llmProviderPresenter = options.llmProviderPresenter + this.configPresenter = options.configPresenter this.exporter = options.exporter this.commandPermissionService = options.commandPermissionService ?? new CommandPermissionService() @@ -258,11 +260,43 @@ export class SessionPresenter implements ISessionPresenter { }) .filter((item) => item.content.length > 0) - const title = await this.llmProviderPresenter.summaryTitles( - formattedMessages, - conversation.settings.providerId, - conversation.settings.modelId + const assistantModel = this.configPresenter.getSetting<{ providerId: string; modelId: string }>( + 'assistantModel' ) + const fallbackProviderId = conversation.settings.providerId + const fallbackModelId = conversation.settings.modelId + const preferredProviderId = assistantModel?.providerId || fallbackProviderId + const preferredModelId = assistantModel?.modelId || fallbackModelId + + let title: string + try { + title = await this.llmProviderPresenter.summaryTitles( + formattedMessages, + preferredProviderId, + preferredModelId + ) + } catch (error) { + const shouldFallback = + preferredProviderId !== fallbackProviderId || preferredModelId !== fallbackModelId + if (!shouldFallback) { + throw error + } + console.warn( + '[SessionPresenter] Failed to generate title with assistant model, fallback to conversation model', + { + preferredProviderId, + preferredModelId, + fallbackProviderId, + fallbackModelId, + error + } + ) + title = await this.llmProviderPresenter.summaryTitles( + formattedMessages, + fallbackProviderId, + fallbackModelId + ) + } let cleanedTitle = title.replace(/.*?<\/think>/g, '').trim() cleanedTitle = cleanedTitle.replace(/^/, '').trim() @@ -342,57 +376,19 @@ export class SessionPresenter implements ISessionPresenter { return existingTabId } - const sourceWindowId = - typeof tabId === 'number' - ? presenter.tabPresenter.getWindowIdByWebContentsId(tabId) - : undefined - const fallbackWindowId = presenter.windowPresenter.getFocusedWindow()?.id - const windowId = sourceWindowId ?? fallbackWindowId - - if (!windowId) { - if (typeof tabId === 'number') { - await this.conversationManager.setActiveConversation(conversationId, tabId) - if (messageId || childConversationId) { - eventBus.sendToTab(tabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { - conversationId, - messageId, - childConversationId - }) - } - return tabId - } - return null - } - - const newTabId = await presenter.tabPresenter.createTab(windowId, 'local://chat', { - active: true - }) - - if (!newTabId) { - if (typeof tabId === 'number') { - await this.conversationManager.setActiveConversation(conversationId, tabId) - if (messageId || childConversationId) { - eventBus.sendToTab(tabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { - conversationId, - messageId, - childConversationId - }) - } - return tabId + // Shell windows no longer create chat tabs; just set active conversation on the current tab + if (typeof tabId === 'number') { + await this.conversationManager.setActiveConversation(conversationId, tabId) + if (messageId || childConversationId) { + eventBus.sendToTab(tabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { + conversationId, + messageId, + childConversationId + }) } - return null + return tabId } - - await this.waitForTabReady(newTabId) - await this.conversationManager.setActiveConversation(conversationId, newTabId) - if (messageId || childConversationId) { - eventBus.sendToTab(newTabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { - conversationId, - messageId, - childConversationId - }) - } - return newTabId + return null } async getActiveConversation(tabId: number): Promise { @@ -725,25 +721,11 @@ export class SessionPresenter implements ISessionPresenter { }) const shouldOpenInNewTab = openInNewTab ?? true - if (shouldOpenInNewTab) { - const sourceWindowId = - typeof tabId === 'number' - ? presenter.tabPresenter.getWindowIdByWebContentsId(tabId) - : undefined - const fallbackWindowId = presenter.windowPresenter.getFocusedWindow()?.id - const windowId = sourceWindowId ?? fallbackWindowId - - if (windowId) { - const newTabId = await presenter.tabPresenter.createTab(windowId, 'local://chat', { - active: true - }) - if (newTabId) { - await this.waitForTabReady(newTabId) - await this.conversationManager.setActiveConversation(newConversationId, newTabId) - await this.broadcastThreadListUpdate() - return newConversationId - } - } + if (shouldOpenInNewTab && typeof tabId === 'number') { + // Shell windows no longer create chat tabs; set active conversation on the current tab + await this.conversationManager.setActiveConversation(newConversationId, tabId) + await this.broadcastThreadListUpdate() + return newConversationId } if (typeof tabId === 'number') { @@ -762,30 +744,6 @@ export class SessionPresenter implements ISessionPresenter { return this.sqlitePresenter.listChildConversationsByMessageIds(parentMessageIds) } - private async waitForTabReady(tabId: number): Promise { - return new Promise((resolve) => { - let resolved = false - const onTabReady = (readyTabId: number) => { - if (readyTabId === tabId && !resolved) { - resolved = true - eventBus.off(TAB_EVENTS.RENDERER_TAB_READY, onTabReady) - clearTimeout(timeoutId) - resolve() - } - } - - eventBus.on(TAB_EVENTS.RENDERER_TAB_READY, onTabReady) - - const timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true - eventBus.off(TAB_EVENTS.RENDERER_TAB_READY, onTabReady) - resolve() - } - }, 3000) - }) - } - /** * 导出会话内容 * @param conversationId 会话ID diff --git a/src/main/presenter/sessionPresenter/managers/conversationManager.ts b/src/main/presenter/sessionPresenter/managers/conversationManager.ts index 24d97c428..9df3ffb30 100644 --- a/src/main/presenter/sessionPresenter/managers/conversationManager.ts +++ b/src/main/presenter/sessionPresenter/managers/conversationManager.ts @@ -185,6 +185,21 @@ export class ConversationManager { defaultSettings.activeSkills = [] } + // Apply global defaultModel if caller didn't specify model and no recent conversation settings + const shouldApplyDefaultModel = + !settings.modelId && + !settings.providerId && + !latestConversation?.settings && + !defaultSettings.acpWorkdirMap?.['default'] + + if (shouldApplyDefaultModel) { + const globalDefaultModel = this.configPresenter.getDefaultModel() + if (globalDefaultModel?.modelId && globalDefaultModel?.providerId) { + defaultSettings.modelId = globalDefaultModel.modelId + defaultSettings.providerId = globalDefaultModel.providerId + } + } + const sanitizedSettings: Partial = { ...settings } Object.keys(sanitizedSettings).forEach((key) => { const typedKey = key as keyof CONVERSATION_SETTINGS diff --git a/src/main/presenter/shortcutPresenter.ts b/src/main/presenter/shortcutPresenter.ts index 530122577..249695c73 100644 --- a/src/main/presenter/shortcutPresenter.ts +++ b/src/main/presenter/shortcutPresenter.ts @@ -197,71 +197,17 @@ export class ShortcutPresenter implements IShortcutPresenter { this.isActive = true } - // 切换到下一个标签页 - private async switchToNextTab(windowId: number): Promise { - try { - const tabsData = await presenter.tabPresenter.getWindowTabsData(windowId) - if (!tabsData || tabsData.length <= 1) return // 只有一个或没有标签页时不执行切换 + // No-op: shell windows no longer manage chat tabs + private async switchToNextTab(_windowId: number): Promise {} - // 找到当前活动标签的索引 - const activeTabIndex = tabsData.findIndex((tab) => tab.isActive) - if (activeTabIndex === -1) return + // No-op: shell windows no longer manage chat tabs + private async switchToPreviousTab(_windowId: number): Promise {} - // 计算下一个标签页的索引(循环到第一个) - const nextTabIndex = (activeTabIndex + 1) % tabsData.length + // No-op: shell windows no longer manage chat tabs + private async switchToTabByIndex(_windowId: number, _index: number): Promise {} - // 切换到下一个标签页 - await presenter.tabPresenter.switchTab(tabsData[nextTabIndex].id) - } catch (error) { - console.error('Failed to switch to next tab:', error) - } - } - - // 切换到上一个标签页 - private async switchToPreviousTab(windowId: number): Promise { - try { - const tabsData = await presenter.tabPresenter.getWindowTabsData(windowId) - if (!tabsData || tabsData.length <= 1) return // 只有一个或没有标签页时不执行切换 - - // 找到当前活动标签的索引 - const activeTabIndex = tabsData.findIndex((tab) => tab.isActive) - if (activeTabIndex === -1) return - - // 计算上一个标签页的索引(循环到最后一个) - const previousTabIndex = (activeTabIndex - 1 + tabsData.length) % tabsData.length - - // 切换到上一个标签页 - await presenter.tabPresenter.switchTab(tabsData[previousTabIndex].id) - } catch (error) { - console.error('Failed to switch to previous tab:', error) - } - } - - // 切换到指定索引的标签页 - private async switchToTabByIndex(windowId: number, index: number): Promise { - try { - const tabsData = await presenter.tabPresenter.getWindowTabsData(windowId) - if (!tabsData || index >= tabsData.length) return // 索引超出范围 - - // 切换到指定索引的标签页 - await presenter.tabPresenter.switchTab(tabsData[index].id) - } catch (error) { - console.error(`Failed to switch to tab at index ${index}:`, error) - } - } - - // 切换到最后一个标签页 - private async switchToLastTab(windowId: number): Promise { - try { - const tabsData = await presenter.tabPresenter.getWindowTabsData(windowId) - if (!tabsData || tabsData.length === 0) return - - // 切换到最后一个标签页 - await presenter.tabPresenter.switchTab(tabsData[tabsData.length - 1].id) - } catch (error) { - console.error('Failed to switch to last tab:', error) - } - } + // No-op: shell windows no longer manage chat tabs + private async switchToLastTab(_windowId: number): Promise {} // Command+O 或 Ctrl+O 显示/隐藏窗口 private async showHideWindow() { diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index d0619c2d8..6a0bd8784 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -14,6 +14,10 @@ import { } from '@shared/presenter' import { MessageAttachmentsTable } from './tables/messageAttachments' import { AcpSessionsTable, type AcpSessionUpsertData } from './tables/acpSessions' +import { NewSessionsTable } from './tables/newSessions' +import { NewProjectsTable } from './tables/newProjects' +import { DeepChatSessionsTable } from './tables/deepchatSessions' +import { DeepChatMessagesTable } from './tables/deepchatMessages' /** * 导入模式枚举 @@ -30,6 +34,10 @@ export class SQLitePresenter implements ISQLitePresenter { private attachmentsTable!: AttachmentsTable private messageAttachmentsTable!: MessageAttachmentsTable private acpSessionsTable!: AcpSessionsTable + public newSessionsTable!: NewSessionsTable + public newProjectsTable!: NewProjectsTable + public deepchatSessionsTable!: DeepChatSessionsTable + public deepchatMessagesTable!: DeepChatMessagesTable private currentVersion: number = 0 private dbPath: string private password?: string @@ -141,6 +149,10 @@ export class SQLitePresenter implements ISQLitePresenter { this.attachmentsTable = new AttachmentsTable(this.db) this.messageAttachmentsTable = new MessageAttachmentsTable(this.db) this.acpSessionsTable = new AcpSessionsTable(this.db) + this.newSessionsTable = new NewSessionsTable(this.db) + this.newProjectsTable = new NewProjectsTable(this.db) + this.deepchatSessionsTable = new DeepChatSessionsTable(this.db) + this.deepchatMessagesTable = new DeepChatMessagesTable(this.db) // 创建所有表 this.conversationsTable.createTable() @@ -148,6 +160,10 @@ export class SQLitePresenter implements ISQLitePresenter { this.attachmentsTable.createTable() this.messageAttachmentsTable.createTable() this.acpSessionsTable.createTable() + this.newSessionsTable.createTable() + this.newProjectsTable.createTable() + this.deepchatSessionsTable.createTable() + this.deepchatMessagesTable.createTable() } private initVersionTable() { @@ -173,7 +189,11 @@ export class SQLitePresenter implements ISQLitePresenter { this.messagesTable, this.attachmentsTable, this.messageAttachmentsTable, - this.acpSessionsTable + this.acpSessionsTable, + this.newSessionsTable, + this.newProjectsTable, + this.deepchatSessionsTable, + this.deepchatMessagesTable ] // 获取最新的迁移版本 diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts b/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts new file mode 100644 index 000000000..396c79cee --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts @@ -0,0 +1,133 @@ +import Database from 'better-sqlite3-multiple-ciphers' +import { BaseTable } from './baseTable' + +export interface DeepChatMessageRow { + id: string + session_id: string + order_seq: number + role: 'user' | 'assistant' + content: string + status: 'pending' | 'sent' | 'error' + is_context_edge: number + metadata: string + created_at: number + updated_at: number +} + +export class DeepChatMessagesTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'deepchat_messages') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS deepchat_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + order_seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + status TEXT DEFAULT 'pending', + is_context_edge INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_deepchat_messages_session ON deepchat_messages(session_id, order_seq); + ` + } + + getMigrationSQL(_version: number): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + insert(row: { + id: string + sessionId: string + orderSeq: number + role: 'user' | 'assistant' + content: string + status: 'pending' | 'sent' | 'error' + }): void { + const now = Date.now() + this.db + .prepare( + `INSERT INTO deepchat_messages (id, session_id, order_seq, role, content, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run(row.id, row.sessionId, row.orderSeq, row.role, row.content, row.status, now, now) + } + + updateContent(messageId: string, content: string): void { + this.db + .prepare('UPDATE deepchat_messages SET content = ?, updated_at = ? WHERE id = ?') + .run(content, Date.now(), messageId) + } + + updateStatus(messageId: string, status: 'pending' | 'sent' | 'error'): void { + this.db + .prepare('UPDATE deepchat_messages SET status = ?, updated_at = ? WHERE id = ?') + .run(status, Date.now(), messageId) + } + + updateContentAndStatus( + messageId: string, + content: string, + status: 'sent' | 'error', + metadata?: string + ): void { + const parts = ['content = ?', 'status = ?', 'updated_at = ?'] + const params: unknown[] = [content, status, Date.now()] + + if (metadata !== undefined) { + parts.push('metadata = ?') + params.push(metadata) + } + + params.push(messageId) + this.db.prepare(`UPDATE deepchat_messages SET ${parts.join(', ')} WHERE id = ?`).run(...params) + } + + getBySession(sessionId: string): DeepChatMessageRow[] { + return this.db + .prepare('SELECT * FROM deepchat_messages WHERE session_id = ? ORDER BY order_seq') + .all(sessionId) as DeepChatMessageRow[] + } + + getIdsBySession(sessionId: string): string[] { + const rows = this.db + .prepare('SELECT id FROM deepchat_messages WHERE session_id = ? ORDER BY order_seq') + .all(sessionId) as { id: string }[] + return rows.map((r) => r.id) + } + + get(messageId: string): DeepChatMessageRow | undefined { + return this.db.prepare('SELECT * FROM deepchat_messages WHERE id = ?').get(messageId) as + | DeepChatMessageRow + | undefined + } + + getMaxOrderSeq(sessionId: string): number { + const row = this.db + .prepare('SELECT MAX(order_seq) as max_seq FROM deepchat_messages WHERE session_id = ?') + .get(sessionId) as { max_seq: number | null } + return row.max_seq ?? 0 + } + + deleteBySession(sessionId: string): void { + this.db.prepare('DELETE FROM deepchat_messages WHERE session_id = ?').run(sessionId) + } + + recoverPendingMessages(): number { + const result = this.db + .prepare( + "UPDATE deepchat_messages SET status = 'error', updated_at = ? WHERE status = 'pending'" + ) + .run(Date.now()) + return result.changes + } +} diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts b/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts new file mode 100644 index 000000000..aeb29de78 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts @@ -0,0 +1,51 @@ +import Database from 'better-sqlite3-multiple-ciphers' +import { BaseTable } from './baseTable' + +export interface DeepChatSessionRow { + id: string + provider_id: string + model_id: string +} + +export class DeepChatSessionsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'deepchat_sessions') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS deepchat_sessions ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL + ); + ` + } + + getMigrationSQL(_version: number): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + create(id: string, providerId: string, modelId: string): void { + this.db + .prepare( + `INSERT INTO deepchat_sessions (id, provider_id, model_id) + VALUES (?, ?, ?)` + ) + .run(id, providerId, modelId) + } + + get(id: string): DeepChatSessionRow | undefined { + return this.db.prepare('SELECT * FROM deepchat_sessions WHERE id = ?').get(id) as + | DeepChatSessionRow + | undefined + } + + delete(id: string): void { + this.db.prepare('DELETE FROM deepchat_sessions WHERE id = ?').run(id) + } +} diff --git a/src/main/presenter/sqlitePresenter/tables/newProjects.ts b/src/main/presenter/sqlitePresenter/tables/newProjects.ts new file mode 100644 index 000000000..1493effed --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/newProjects.ts @@ -0,0 +1,63 @@ +import Database from 'better-sqlite3-multiple-ciphers' +import { BaseTable } from './baseTable' + +export interface NewProjectRow { + path: string + name: string + icon: string | null + last_accessed_at: number +} + +export class NewProjectsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'new_projects') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS new_projects ( + path TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT DEFAULT NULL, + last_accessed_at INTEGER NOT NULL + ); + ` + } + + getMigrationSQL(_version: number): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + upsert(projectPath: string, name: string, icon: string | null = null): void { + this.db + .prepare( + `INSERT INTO new_projects (path, name, icon, last_accessed_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + name = excluded.name, + icon = COALESCE(excluded.icon, new_projects.icon), + last_accessed_at = excluded.last_accessed_at` + ) + .run(projectPath, name, icon, Date.now()) + } + + getAll(): NewProjectRow[] { + return this.db + .prepare('SELECT * FROM new_projects ORDER BY last_accessed_at DESC') + .all() as NewProjectRow[] + } + + getRecent(limit: number): NewProjectRow[] { + return this.db + .prepare('SELECT * FROM new_projects ORDER BY last_accessed_at DESC LIMIT ?') + .all(limit) as NewProjectRow[] + } + + delete(projectPath: string): void { + this.db.prepare('DELETE FROM new_projects WHERE path = ?').run(projectPath) + } +} diff --git a/src/main/presenter/sqlitePresenter/tables/newSessions.ts b/src/main/presenter/sqlitePresenter/tables/newSessions.ts new file mode 100644 index 000000000..f93b843f5 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/newSessions.ts @@ -0,0 +1,113 @@ +import Database from 'better-sqlite3-multiple-ciphers' +import { BaseTable } from './baseTable' + +export interface NewSessionRow { + id: string + agent_id: string + title: string + project_dir: string | null + is_pinned: number + created_at: number + updated_at: number +} + +export class NewSessionsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'new_sessions') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS new_sessions ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + title TEXT NOT NULL, + project_dir TEXT, + is_pinned INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_new_sessions_agent ON new_sessions(agent_id); + CREATE INDEX IF NOT EXISTS idx_new_sessions_updated ON new_sessions(updated_at DESC); + ` + } + + getMigrationSQL(_version: number): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + create(id: string, agentId: string, title: string, projectDir: string | null): void { + const now = Date.now() + this.db + .prepare( + `INSERT INTO new_sessions (id, agent_id, title, project_dir, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run(id, agentId, title, projectDir, now, now) + } + + get(id: string): NewSessionRow | undefined { + return this.db.prepare('SELECT * FROM new_sessions WHERE id = ?').get(id) as + | NewSessionRow + | undefined + } + + list(filters?: { agentId?: string; projectDir?: string }): NewSessionRow[] { + let sql = 'SELECT * FROM new_sessions' + const conditions: string[] = [] + const params: unknown[] = [] + + if (filters?.agentId) { + conditions.push('agent_id = ?') + params.push(filters.agentId) + } + if (filters?.projectDir) { + conditions.push('project_dir = ?') + params.push(filters.projectDir) + } + + if (conditions.length > 0) { + sql += ' WHERE ' + conditions.join(' AND ') + } + sql += ' ORDER BY updated_at DESC' + + return this.db.prepare(sql).all(...params) as NewSessionRow[] + } + + update( + id: string, + fields: Partial> + ): void { + const setClauses: string[] = [] + const params: unknown[] = [] + + if (fields.title !== undefined) { + setClauses.push('title = ?') + params.push(fields.title) + } + if (fields.project_dir !== undefined) { + setClauses.push('project_dir = ?') + params.push(fields.project_dir) + } + if (fields.is_pinned !== undefined) { + setClauses.push('is_pinned = ?') + params.push(fields.is_pinned) + } + + if (setClauses.length === 0) return + + setClauses.push('updated_at = ?') + params.push(Date.now()) + params.push(id) + + this.db.prepare(`UPDATE new_sessions SET ${setClauses.join(', ')} WHERE id = ?`).run(...params) + } + + delete(id: string): void { + this.db.prepare('DELETE FROM new_sessions WHERE id = ?').run(id) + } +} diff --git a/src/main/presenter/syncPresenter/index.ts b/src/main/presenter/syncPresenter/index.ts index e5e75dd4f..f892f070f 100644 --- a/src/main/presenter/syncPresenter/index.ts +++ b/src/main/presenter/syncPresenter/index.ts @@ -564,44 +564,7 @@ export class SyncPresenter implements ISyncPresenter { } private async resetShellWindowsToSingleNewChatTab(): Promise { - try { - const { presenter } = await import('../index') - const windowPresenter = presenter?.windowPresenter as any - const tabPresenter = presenter?.tabPresenter as any - - const windows = (windowPresenter?.getAllWindows?.() as Array<{ id: number }>) ?? [] - await Promise.all( - windows.map(async ({ id: windowId }) => { - const tabsData = - (await tabPresenter?.getWindowTabsData?.(windowId)) ?? - ([] as Array<{ id: number; isActive?: boolean }>) - - if (tabsData.length === 0) { - await tabPresenter?.createTab?.(windowId, 'local://chat', { active: true }) - return - } - - const tabToKeep = tabsData.find((tab) => tab.isActive) ?? tabsData[0] - if (!tabToKeep) { - return - } - - await tabPresenter?.resetTabToBlank?.(tabToKeep.id) - await tabPresenter?.switchTab?.(tabToKeep.id) - - const tabsToClose = tabsData.filter((tab) => tab.id !== tabToKeep.id).map((tab) => tab.id) - for (const tabId of tabsToClose) { - try { - await tabPresenter?.closeTab?.(tabId) - } catch (error) { - console.warn('Failed to close tab after overwrite import:', tabId, error) - } - } - }) - ) - } catch (error) { - console.warn('Failed to reset shell windows after overwrite import:', error) - } + // Shell windows no longer manage chat tabs; nothing to reset } private cleanupDatabaseSidecarFiles(dbFilePath: string): void { diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 3633f6a33..6fbdc6ea8 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -6,10 +6,9 @@ import iconWin from '../../../../resources/icon.ico?asset' // App icon (Windows) import { is } from '@electron-toolkit/utils' // Electron utilities import { IConfigPresenter, IWindowPresenter } from '@shared/presenter' // Window Presenter interface import { eventBus } from '@/eventbus' // Event bus -import { CONFIG_EVENTS, SYSTEM_EVENTS, WINDOW_EVENTS } from '@/events' // System/Window/Config event constants +import { CONFIG_EVENTS, SHORTCUT_EVENTS, SYSTEM_EVENTS, WINDOW_EVENTS } from '@/events' // System/Window/Config/Shortcut event constants import { presenter } from '../' // Global presenter registry import windowStateManager from 'electron-window-state' // Window state manager -import { SHORTCUT_EVENTS } from '@/events' // Shortcut event constants // TrayPresenter is globally managed in main/index.ts, this Presenter is not responsible for its lifecycle import { TabPresenter } from '../tabPresenter' // TabPresenter type import { FloatingChatWindow } from './FloatingChatWindow' // Floating chat window @@ -28,16 +27,6 @@ export class WindowPresenter implements IWindowPresenter { private focusedWindowId: number | null = null // Main window ID private mainWindowId: number | null = null - // Window focus state management - private windowFocusStates = new Map< - number, - { - lastFocusTime: number - shouldFocus: boolean - isNewWindow: boolean - hasInitialFocus: boolean - } - >() private floatingChatWindow: FloatingChatWindow | null = null private settingsWindow: BrowserWindow | null = null private tooltipOverlayWindows = new Map() @@ -59,6 +48,7 @@ export class WindowPresenter implements IWindowPresenter { event.returnValue = event.sender.id }) + // Chrome height reporting from browser windows (TabPresenter uses this for view bounds) ipcMain.on('shell:chrome-height', (event, payload: { height?: number } | number) => { const window = BrowserWindow.fromWebContents(event.sender) if (!window || window.isDestroyed()) return @@ -132,60 +122,7 @@ export class WindowPresenter implements IWindowPresenter { // Listen for shortcut event: create new window eventBus.on(SHORTCUT_EVENTS.CREATE_NEW_WINDOW, () => { console.log('Creating new shell window via shortcut.') - this.createShellWindow({ initialTab: { url: 'local://chat' } }) - }) - - // Listen for shortcut event: create new tab - eventBus.on(SHORTCUT_EVENTS.CREATE_NEW_TAB, async (windowId: number) => { - console.log(`Creating new tab via shortcut for window ${windowId}.`) - const window = this.windows.get(windowId) - if (window && !window.isDestroyed()) { - await (presenter.tabPresenter as TabPresenter).createTab(windowId, 'local://chat', { - active: true - }) - } else { - console.warn( - `Cannot create new tab for window ${windowId}, window does not exist or is destroyed.` - ) - } - }) - - // 监听快捷键事件:关闭当前标签页 - eventBus.on(SHORTCUT_EVENTS.CLOSE_CURRENT_TAB, async (windowId: number) => { - console.log(`Received CLOSE_CURRENT_TAB for window ${windowId}.`) - const window = this.windows.get(windowId) - if (!window || window.isDestroyed()) { - console.warn( - `Cannot handle close tab request, window ${windowId} does not exist or is destroyed.` - ) - return - } - - const tabPresenterInstance = presenter.tabPresenter as TabPresenter - const tabsData = await tabPresenterInstance.getWindowTabsData(windowId) - const activeTab = tabsData.find((tab) => tab.isActive) - - if (activeTab) { - if (tabsData.length === 1) { - // 窗口内只有最后一个标签页 - const allWindows = this.getAllWindows() - if (allWindows.length === 1) { - // 是最后一个窗口的最后一个标签页,隐藏窗口 - console.log(`Window ${windowId} is the last window's last tab, hiding window.`) - this.hide(windowId) // 调用 hide() 会触发 hide 逻辑 - } else { - // 不是最后一个窗口的最后一个标签页,关闭窗口 - console.log(`Window ${windowId} has other windows, closing this window.`) - this.close(windowId) // 调用 close() 会触发 'close' 事件处理器 - } - } else { - // 窗口内不止一个标签页,直接关闭当前标签页 - console.log(`Window ${windowId} has multiple tabs, closing active tab ${activeTab.id}.`) - await tabPresenterInstance.closeTab(activeTab.id) - } - } else { - console.warn(`No active tab found in window ${windowId} to close.`) - } + this.createShellWindow() }) // Listen for shortcut event: go settings (now opens independent Settings Window) @@ -437,7 +374,6 @@ export class WindowPresenter implements IWindowPresenter { /** * 窗口恢复、显示或尺寸变更后的处理逻辑。 - * 主要确保当前活动标签页的 WebContentsView 可见且位置正确。 * @param windowId 窗口 ID。 */ private async handleWindowRestore(windowId: number): Promise { @@ -449,32 +385,6 @@ export class WindowPresenter implements IWindowPresenter { ) return } - - try { - // 通过 TabPresenter 获取活动标签页 ID - const tabPresenterInstance = presenter.tabPresenter as TabPresenter - const activeTabId = await tabPresenterInstance.getActiveTabId(windowId) - - if (activeTabId) { - console.log(`Window ${windowId} restored/shown: activating active tab ${activeTabId}.`) - // 调用 switchTab 会确保视图被关联、可见并更新 bounds - await tabPresenterInstance.switchTab(activeTabId) - } else { - console.warn( - `Window ${windowId} restored/shown: no active tab found, ensuring all views are hidden.` - ) - // 如果没有活动标签页,确保所有视图都隐藏 - const tabsInWindow = await tabPresenterInstance.getWindowTabsData(windowId) - for (const tabData of tabsInWindow) { - const tabView = await tabPresenterInstance.getTab(tabData.id) - if (tabView && !tabView.webContents.isDestroyed()) { - tabView.setVisible(false) // 显式隐藏所有标签页视图 - } - } - } - } catch (error) { - console.error(`Error handling restore/show logic for window ${windowId}:`, error) - } } /** @@ -497,76 +407,6 @@ export class WindowPresenter implements IWindowPresenter { return focusedWindow ? focusedWindow.id === windowId : false } - /** - * 检查是否应该聚焦标签页 - * @param windowId 窗口 ID - * @param reason 聚焦原因 - */ - private shouldFocusTab( - windowId: number, - reason: 'focus' | 'restore' | 'show' | 'initial' - ): boolean { - const state = this.windowFocusStates.get(windowId) - if (!state) { - return true - } - const now = Date.now() - if (now - state.lastFocusTime < 100) { - console.log(`Skipping focus for window ${windowId}, too frequent (${reason})`) - return false - } - switch (reason) { - case 'initial': - return !state.hasInitialFocus - case 'focus': - return state.shouldFocus - case 'restore': - case 'show': - return state.isNewWindow || state.shouldFocus - default: - return false - } - } - - /** - * 将焦点传递给指定窗口的活动标签页 - * @param windowId 窗口 ID - * @param reason 聚焦原因 - */ - public focusActiveTab( - windowId: number, - reason: 'focus' | 'restore' | 'show' | 'initial' = 'focus' - ): void { - if (!this.shouldFocusTab(windowId, reason)) { - return - } - try { - setTimeout(async () => { - const tabPresenterInstance = presenter.tabPresenter as TabPresenter - const tabsData = await tabPresenterInstance.getWindowTabsData(windowId) - const activeTab = tabsData.find((tab) => tab.isActive) - if (activeTab) { - console.log( - `Focusing active tab ${activeTab.id} in window ${windowId} (reason: ${reason})` - ) - await tabPresenterInstance.switchTab(activeTab.id) - const state = this.windowFocusStates.get(windowId) - if (state) { - state.lastFocusTime = Date.now() - if (reason === 'initial') { - state.hasInitialFocus = true - } - if (reason === 'focus' || reason === 'initial') { - state.isNewWindow = false - } - } - } - }, 50) - } catch (error) { - console.error(`Error focusing active tab in window ${windowId}:`, error) - } - } - /** * 向所有有效窗口的主 WebContents 和所有标签页的 WebContents 发送消息。 * @param channel IPC 通道名。 @@ -759,14 +599,10 @@ export class WindowPresenter implements IWindowPresenter { const windowId = shellWindow.id this.windows.set(windowId, shellWindow) // 将窗口实例存入 Map - ;(presenter.tabPresenter as TabPresenter).setWindowType(windowId, windowType) - - this.windowFocusStates.set(windowId, { - lastFocusTime: 0, - shouldFocus: true, - isNewWindow: true, - hasInitialFocus: false - }) + // For browser windows, register type with TabPresenter + if (windowType === 'browser') { + ;(presenter.tabPresenter as TabPresenter).setWindowType(windowId, windowType) + } shellWindowState.manage(shellWindow) // 管理窗口状态 @@ -787,8 +623,6 @@ export class WindowPresenter implements IWindowPresenter { if (!shellWindow.isDestroyed()) { // For browser windows, don't auto-show/focus to prevent stealing focus from chat windows // Browser windows should only be shown when explicitly requested by user (e.g., clicking browser button) - const tabPresenterInstance = presenter.tabPresenter as TabPresenter - const windowType = tabPresenterInstance.getWindowType(windowId) const shouldAutoShow = windowType !== 'browser' || options?.forMovedTab === true if (shouldAutoShow) { @@ -809,7 +643,6 @@ export class WindowPresenter implements IWindowPresenter { if (!shellWindow.isDestroyed()) { shellWindow.webContents.send('window-focused', windowId) } - this.focusActiveTab(windowId, 'focus') }) // 窗口失去焦点 @@ -860,7 +693,6 @@ export class WindowPresenter implements IWindowPresenter { this.handleWindowRestore(windowId).catch((error) => { console.error(`Error handling restore logic for window ${windowId}:`, error) }) - this.focusActiveTab(windowId, 'restore') shellWindow.webContents.send(WINDOW_EVENTS.WINDOW_UNMAXIMIZED) eventBus.sendToMain(WINDOW_EVENTS.WINDOW_RESTORED, windowId) } @@ -952,7 +784,6 @@ export class WindowPresenter implements IWindowPresenter { console.log( `Window ${windowId}: Allowing default close behavior (app is quitting or macOS last window configured to quit).` ) - presenter.tabPresenter.closeTabs(windowId) } } else { // 如果 isQuitting 为 true,表示应用正在主动退出,允许窗口正常关闭 @@ -971,7 +802,6 @@ export class WindowPresenter implements IWindowPresenter { shellWindow.removeListener('restore', handleRestore) this.windows.delete(windowIdBeingClosed) // 从 Map 中移除 - this.windowFocusStates.delete(windowIdBeingClosed) shellWindowState.unmanage() // 停止管理窗口状态 eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CLOSED, windowIdBeingClosed) this.destroyTooltipOverlay(windowIdBeingClosed) @@ -992,86 +822,102 @@ export class WindowPresenter implements IWindowPresenter { }) // --- 加载 Renderer HTML 文件 --- - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - console.log( - `Loading renderer URL in dev mode: ${process.env['ELECTRON_RENDERER_URL']}/shell/index.html` - ) - shellWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/shell/index.html') + if (windowType === 'chat') { + // Chat windows load the main renderer directly with #/chat hash route + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + console.log( + `Loading main renderer URL in dev mode: ${process.env['ELECTRON_RENDERER_URL']}#/chat` + ) + shellWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/chat') + } else { + console.log( + `Loading packaged main renderer file: ${join(__dirname, '../renderer/index.html')}` + ) + shellWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash: '/chat' }) + } } else { - // 生产模式下加载打包后的 HTML 文件 - console.log( - `Loading packaged renderer file: ${join(__dirname, '../renderer/shell/index.html')}` - ) - shellWindow.loadFile(join(__dirname, '../renderer/shell/index.html')) + // Browser windows load the shell renderer + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + console.log( + `Loading renderer URL in dev mode: ${process.env['ELECTRON_RENDERER_URL']}/shell/index.html` + ) + shellWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/shell/index.html') + } else { + console.log( + `Loading packaged renderer file: ${join(__dirname, '../renderer/shell/index.html')}` + ) + shellWindow.loadFile(join(__dirname, '../renderer/shell/index.html')) + } } // Pre-create tooltip overlay so first hover is instant shellWindow.webContents.once('did-finish-load', () => { if (shellWindow.isDestroyed()) return - shellWindow.webContents.send('shell-window:type', windowType) + // Only send shell-window:type for browser windows (shell renderer listens for it) + if (windowType === 'browser') { + shellWindow.webContents.send('shell-window:type', windowType) + } // Avoid pre-creating overlay if window already in fullscreen on macOS if (!(process.platform === 'darwin' && shellWindow.isFullScreen())) { this.getOrCreateTooltipOverlay(shellWindow) } }) - // --- 处理初始标签页创建或激活 --- - - // 如果提供了 options?.initialTab,等待窗口加载完成,然后创建新标签页 - if (options?.initialTab) { - shellWindow.webContents.once('did-finish-load', async () => { - console.log(`Window ${windowId} did-finish-load, checking for initial tab creation.`) - if (shellWindow.isDestroyed()) { - console.warn( - `Window ${windowId} was destroyed before did-finish-load callback, cannot create initial tab.` - ) - return - } - shellWindow.focus() // 窗口加载完成后聚焦 - try { - console.log(`Creating initial tab, URL: ${options.initialTab!.url}`) - const tabId = await (presenter.tabPresenter as TabPresenter).createTab( - windowId, - options.initialTab!.url, - { active: true } - ) - if (tabId === null) { - console.error(`Failed to create initial tab in new window ${windowId}.`) - } else { - console.log(`Created initial tab ${tabId} in window ${windowId}.`) + // --- 处理 browser 窗口的初始标签页创建或激活 --- + // Only browser windows need initial tab / activateTab handling via TabPresenter + if (windowType === 'browser') { + if (options?.initialTab) { + shellWindow.webContents.once('did-finish-load', async () => { + console.log(`Window ${windowId} did-finish-load, checking for initial tab creation.`) + if (shellWindow.isDestroyed()) { + console.warn( + `Window ${windowId} was destroyed before did-finish-load callback, cannot create initial tab.` + ) + return } - } catch (error) { - console.error(`Error creating initial tab:`, error) - } - }) - } + shellWindow.focus() + try { + console.log(`Creating initial tab, URL: ${options.initialTab!.url}`) + const tabId = await (presenter.tabPresenter as TabPresenter).createTab( + windowId, + options.initialTab!.url, + { active: true } + ) + if (tabId === null) { + console.error(`Failed to create initial tab in new window ${windowId}.`) + } else { + console.log(`Created initial tab ${tabId} in window ${windowId}.`) + } + } catch (error) { + console.error(`Error creating initial tab:`, error) + } + }) + } - // 如果提供了 activateTabId,表示一个现有标签页 (WebContentsView) 将被 TabPresenter 关联到此新窗口 - // 拖拽分离的场景在 attachTab 内激活,这里跳过以避免重复激活 - // 激活逻辑 (设置可见性、bounds) 在 tabPresenter.attachTab / switchTab 中处理 - if (options?.activateTabId !== undefined && !options?.forMovedTab) { - // 等待窗口加载完成,然后尝试激活指定标签页 - shellWindow.webContents.once('did-finish-load', async () => { - console.log( - `Window ${windowId} did-finish-load, attempting to activate tab ${options.activateTabId}.` - ) - if (shellWindow.isDestroyed()) { - console.warn( - `Window ${windowId} was destroyed before did-finish-load callback, cannot activate tab ${options.activateTabId}.` - ) - return - } - try { - // 切换到指定标签页,这将处理视图的关联和显示 - await (presenter.tabPresenter as TabPresenter).switchTab(options.activateTabId as number) - console.log(`Requested to switch to tab ${options.activateTabId}.`) - } catch (error) { - console.error( - `Failed to activate tab ${options.activateTabId} after window ${windowId} load:`, - error + if (options?.activateTabId !== undefined && !options?.forMovedTab) { + shellWindow.webContents.once('did-finish-load', async () => { + console.log( + `Window ${windowId} did-finish-load, attempting to activate tab ${options.activateTabId}.` ) - } - }) + if (shellWindow.isDestroyed()) { + console.warn( + `Window ${windowId} was destroyed before did-finish-load callback, cannot activate tab ${options.activateTabId}.` + ) + return + } + try { + await (presenter.tabPresenter as TabPresenter).switchTab( + options.activateTabId as number + ) + console.log(`Requested to switch to tab ${options.activateTabId}.`) + } catch (error) { + console.error( + `Failed to activate tab ${options.activateTabId} after window ${windowId} load:`, + error + ) + } + }) + } } // DevTools 不再自动打开,需要手动通过菜单或快捷键打开 @@ -1333,6 +1179,13 @@ export class WindowPresenter implements IWindowPresenter { ) } } else { + // Fallback: chat windows have no tabs, send directly to BrowserWindow webContents + const targetWindow = BrowserWindow.fromId(windowId) + if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isDestroyed()) { + targetWindow.webContents.send(channel, ...args) + console.log(` - No active tab, sent event directly to window ${windowId} webContents.`) + return true + } console.warn(`No active tab found in window ${windowId}, cannot send event "${channel}".`) } return false @@ -1376,7 +1229,23 @@ export class WindowPresenter implements IWindowPresenter { const tabPresenterInstance = presenter.tabPresenter as TabPresenter const tabsData = await tabPresenterInstance.getWindowTabsData(windowId) if (tabsData.length === 0) { - console.warn(`Window ${windowId} has no tabs, cannot send message to default tab.`) + // Fallback: chat windows have no tabs, send directly to BrowserWindow webContents + if ( + targetWindow && + !targetWindow.isDestroyed() && + !targetWindow.webContents.isDestroyed() + ) { + targetWindow.webContents.send(channel, ...args) + console.log( + ` - Window ${windowId} has no tabs, sent message directly to window webContents.` + ) + if (switchToTarget) { + targetWindow.show() + targetWindow.focus() + } + return true + } + console.warn(`Window ${windowId} has no tabs and window is unavailable.`) return false } diff --git a/src/preload/index.ts b/src/preload/index.ts index b4672fa4b..b789aba1b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,17 @@ import { } from 'electron' import { exposeElectronAPI } from '@electron-toolkit/preload' +const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:', 'deepchat:'] + +const isValidExternalUrl = (url: string): boolean => { + try { + const parsed = new URL(url) + return ALLOWED_PROTOCOLS.includes(parsed.protocol.toLowerCase()) + } catch { + return false + } +} + // Cache variables let cachedWindowId: number | undefined = undefined let cachedWebContentsId: number | undefined = undefined @@ -44,6 +55,10 @@ const api = { return cachedWebContentsId }, openExternal: (url: string) => { + if (!isValidExternalUrl(url)) { + console.warn('Preload: Blocked openExternal for disallowed URL:', url) + return Promise.reject(new Error('URL protocol not allowed')) + } return shell.openExternal(url) }, toRelativePath: (filePath: string, baseDir?: string) => { diff --git a/src/renderer/settings/components/CommonSettings.vue b/src/renderer/settings/components/CommonSettings.vue index 9e64ffa6d..ad0ed5a1d 100644 --- a/src/renderer/settings/components/CommonSettings.vue +++ b/src/renderer/settings/components/CommonSettings.vue @@ -2,6 +2,7 @@
+ +
+
+ + {{ t('settings.common.defaultModel.title') }} +
+ +
+ {{ + t('settings.common.searchAssistantModel') + }} +
+ + + + + + + + +
+
+ +
+ {{ + t('settings.common.defaultModel.chatModel') + }} +
+ + + + + + + + +
+
+ +
+ {{ + t('settings.common.defaultModel.visionModel') + }} +
+ + + + + + + + +
+
+
+ + + diff --git a/src/renderer/shell/App.vue b/src/renderer/shell/App.vue index 6f49aeffd..8d42bfaa9 100644 --- a/src/renderer/shell/App.vue +++ b/src/renderer/shell/App.vue @@ -1,6 +1,6 @@