` 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
+
+
+
+
+
+ {{ group.label }}
+
+
+
+
+
+```
+
+## 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 @@
-
+
import { computed, nextTick, onMounted, ref, watch } from 'vue'
-import { useRouter } from 'vue-router'
import AppBar from './components/AppBar.vue'
import BrowserToolbar from './components/BrowserToolbar.vue'
import BrowserPlaceholder from './components/BrowserPlaceholder.vue'
import { useDeviceVersion } from '@/composables/useDeviceVersion'
-import { useMcpStore } from '@/stores/mcp'
import { useTabStore } from '@shell/stores/tab'
import { useElementSize } from '@vueuse/core'
import { useFontManager } from '@/composables/useFontManager'
@@ -30,11 +28,8 @@ setupFontListener()
// Detect platform to apply proper styling
const { isWinMacOS } = useDeviceVersion()
-const router = useRouter()
-const mcpStore = useMcpStore()
const tabStore = useTabStore()
-const windowType = ref<'chat' | 'browser'>('chat')
const windowId = ref(null)
const appBarRef = ref | null>(null)
const toolbarRef = ref | null>(null)
@@ -49,14 +44,11 @@ const isAboutBlank = computed(() => {
const tab = activeTab.value
return tab?.url === 'about:blank'
})
-const shouldShowToolbar = computed(() => windowType.value === 'browser' && isWebTabActive.value)
-const shouldShowPlaceholder = computed(
- () => windowType.value === 'browser' && isWebTabActive.value && isAboutBlank.value
-)
-const webContentBackgroundClass = computed(() =>
- windowType.value === 'browser' && isWebTabActive.value ? 'bg-white' : ''
-)
+const shouldShowToolbar = computed(() => isWebTabActive.value)
+const shouldShowPlaceholder = computed(() => isWebTabActive.value && isAboutBlank.value)
+const webContentBackgroundClass = computed(() => (isWebTabActive.value ? 'bg-white' : ''))
+// Chrome height reporting — needed for browser windows (TabPresenter manages view bounds)
const appBarSize = useElementSize(computed(() => appBarRef.value?.$el ?? null))
const toolbarSize = useElementSize(computed(() => toolbarRef.value?.$el ?? null))
@@ -83,52 +75,6 @@ onMounted(async () => {
windowId.value = window.api.getWindowId?.() ?? null
await nextTick()
sendChromeHeight(chromeHeight.value)
- window.electron.ipcRenderer.once('shell-window:type', (_event, type: 'chat' | 'browser') => {
- windowType.value = type === 'browser' ? 'browser' : 'chat'
- })
-
- // Check for pending MCP install from localStorage (cold start scenario)
- try {
- const pendingMcpInstall = localStorage.getItem('pending-mcp-install')
- if (pendingMcpInstall) {
- console.log('Found pending MCP install in localStorage (cold start):', pendingMcpInstall)
- // Clear the localStorage immediately to prevent re-processing
- localStorage.removeItem('pending-mcp-install')
-
- // Parse and process the MCP configuration
- const mcpConfig = JSON.parse(pendingMcpInstall)
-
- if (!mcpConfig?.mcpServers || typeof mcpConfig.mcpServers !== 'object') {
- console.error('Invalid MCP install config, missing mcpServers')
- return
- }
-
- // Enable MCP if not already enabled
- if (!mcpStore.mcpEnabled) {
- await mcpStore.setMcpEnabled(true)
- }
-
- // Set the MCP install cache
- mcpStore.setMcpInstallCache(JSON.stringify(mcpConfig))
-
- // Navigate to MCP settings page
- const currentRoute = router.currentRoute.value
- if (currentRoute.name !== 'settings-mcp') {
- await router.push({ name: 'settings-mcp' })
- } else {
- await router.replace({
- name: 'settings-mcp',
- query: { ...currentRoute.query }
- })
- }
-
- console.log('MCP install deeplink processed successfully from cold start')
- }
- } catch (error) {
- console.error('Error processing pending MCP install from cold start:', error)
- // Clear potentially corrupted data
- localStorage.removeItem('pending-mcp-install')
- }
})
diff --git a/src/renderer/shell/components/AppBar.vue b/src/renderer/shell/components/AppBar.vue
index d9ef7c29f..ad34f56d8 100644
--- a/src/renderer/shell/components/AppBar.vue
+++ b/src/renderer/shell/components/AppBar.vue
@@ -12,6 +12,7 @@
class="h-full shrink-0 w-0 flex-1 flex select-none text-center text-sm font-medium flex-row items-center justify-start window-drag-region"
>
+
diff --git a/src/renderer/src/components/mock/MockChatPage.vue b/src/renderer/src/components/mock/MockChatPage.vue
new file mode 100644
index 000000000..fda8eaa27
--- /dev/null
+++ b/src/renderer/src/components/mock/MockChatPage.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/mock/MockInputBox.vue b/src/renderer/src/components/mock/MockInputBox.vue
new file mode 100644
index 000000000..133b6775f
--- /dev/null
+++ b/src/renderer/src/components/mock/MockInputBox.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/mock/MockInputToolbar.vue b/src/renderer/src/components/mock/MockInputToolbar.vue
new file mode 100644
index 000000000..26353038f
--- /dev/null
+++ b/src/renderer/src/components/mock/MockInputToolbar.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+ Attach
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Voice input
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/mock/MockMessageList.vue b/src/renderer/src/components/mock/MockMessageList.vue
new file mode 100644
index 000000000..df483226e
--- /dev/null
+++ b/src/renderer/src/components/mock/MockMessageList.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
Claude 4 Sonnet
+
{{ msg.content }}
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/mock/MockStatusBar.vue b/src/renderer/src/components/mock/MockStatusBar.vue
new file mode 100644
index 000000000..b032dfefc
--- /dev/null
+++ b/src/renderer/src/components/mock/MockStatusBar.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+ Claude 4 Sonnet
+
+
+
+
+
+
+ Claude 4 Sonnet
+
+
+
+ GPT-4o
+
+
+
+ Gemini 2.5 Pro
+
+
+
+
+
+
+
+
+
+ High
+
+
+
+
+ Low
+ Medium
+ High
+ Extra High
+
+
+
+
+
+
+
+
+
+ Default permissions
+
+
+
+
+ Default permissions
+ Restricted
+ Full access
+
+
+
+
+
+
diff --git a/src/renderer/src/components/mock/MockTopBar.vue b/src/renderer/src/components/mock/MockTopBar.vue
new file mode 100644
index 000000000..91ab932ec
--- /dev/null
+++ b/src/renderer/src/components/mock/MockTopBar.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+ {{ projectName }}
+
+
+
{{ title }}
+
+
+
+
+
+
+
+
+
+ Share
+
+
+
+
+
+
+
+
+ More
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/mock/MockWelcomePage.vue b/src/renderer/src/components/mock/MockWelcomePage.vue
new file mode 100644
index 000000000..038dada77
--- /dev/null
+++ b/src/renderer/src/components/mock/MockWelcomePage.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+

+
+
+
+
Welcome to DeepChat Agent
+
+ Connect a model provider to start build
+
+
+
+
+
+
+ {{ provider.name }}
+
+
+
+
+ Browse all providers...
+
+
+
+
+
+
+
or connect an agent
+
+
+
+
+
+
+
+
+
Set up an ACP agent
+
Claude Code, Codex, Kimi, or your own
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/composables/useMockViewState.ts b/src/renderer/src/composables/useMockViewState.ts
new file mode 100644
index 000000000..ebf8a8cb2
--- /dev/null
+++ b/src/renderer/src/composables/useMockViewState.ts
@@ -0,0 +1,23 @@
+import { ref, computed } from 'vue'
+
+const _selectedSessionId = ref(null)
+const _selectedSessionTitle = ref('')
+const _selectedSessionProject = ref('')
+const _showMockWelcome = ref(false)
+
+export function useMockViewState() {
+ const selectSession = (id: string | null, title: string = '', projectDir: string = '') => {
+ _selectedSessionId.value = id
+ _selectedSessionTitle.value = title
+ _selectedSessionProject.value = projectDir
+ }
+
+ return {
+ mockSessionId: _selectedSessionId,
+ mockSessionTitle: _selectedSessionTitle,
+ mockSessionProject: _selectedSessionProject,
+ showMockWelcome: _showMockWelcome,
+ isMockChatActive: computed(() => _selectedSessionId.value !== null),
+ selectSession
+ }
+}
diff --git a/src/renderer/src/events.ts b/src/renderer/src/events.ts
index 74760f8ee..da5309ca6 100644
--- a/src/renderer/src/events.ts
+++ b/src/renderer/src/events.ts
@@ -66,7 +66,11 @@ export const WINDOW_EVENTS = {
READY_TO_SHOW: 'window:ready-to-show', // 替代 main-window-ready-to-show
FORCE_QUIT_APP: 'window:force-quit-app', // 替代 force-quit-app
APP_FOCUS: 'app:focus',
- APP_BLUR: 'app:blur'
+ APP_BLUR: 'app:blur',
+ WINDOW_MAXIMIZED: 'window:maximized',
+ WINDOW_UNMAXIMIZED: 'window:unmaximized',
+ WINDOW_ENTER_FULL_SCREEN: 'window:enter-full-screen',
+ WINDOW_LEAVE_FULL_SCREEN: 'window:leave-full-screen'
}
// Settings related events
@@ -199,6 +203,14 @@ export const RAG_EVENTS = {
FILE_UPDATED: 'rag:file-updated', // 文件状态更新
FILE_PROGRESS: 'rag:file-progress' // 文件进度更新
}
+// 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/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json
index 38d0cd90b..2fd955495 100644
--- a/src/renderer/src/i18n/da-DK/settings.json
+++ b/src/renderer/src/i18n/da-DK/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "Når DeepChat ikke er i forgrunden, sendes der en systemmeddelelse, når der genereres et svar",
"contentProtection": "Skærmoptagelsesbeskyttelse",
"fileMaxSize": "Maksimal filstørrelse",
- "fileMaxSizeHint": "Begrænser størrelsen på en enkelt uploadet fil"
+ "fileMaxSizeHint": "Begrænser størrelsen på en enkelt uploadet fil",
+ "defaultModel": {
+ "title": "Standardmodel",
+ "chatModel": "Standard chatmodel",
+ "visionModel": "Standard visionsmodel"
+ }
},
"data": {
"title": "Dataindstillinger",
@@ -741,7 +746,7 @@
"parseAndContinue": "Fortolk og fortsæt",
"jsonParseError": "JSON-fortolkning mislykkedes",
"browseMarketplace": "Gennemse MCP Marketplace",
- "imageModel": "Vælg en vision-model",
+ "imageModel": "Vision-model",
"customHeadersParseError": "Fortolkning af brugerdefinerede headers mislykkedes",
"customHeaders": "Brugerdefinerede forespørgselsheaders",
"clickToEdit": "Klik for at redigere og se hele indholdet",
diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json
index bebd34f89..a166e0378 100644
--- a/src/renderer/src/i18n/en-US/settings.json
+++ b/src/renderer/src/i18n/en-US/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "When DeepChat is not in the foreground, if a session is generated, a system notification will be sent",
"contentProtection": "Screen capture protection",
"fileMaxSize": "File Maximum Size",
- "fileMaxSizeHint": "Limits the maximum size of a single uploaded file"
+ "fileMaxSizeHint": "Limits the maximum size of a single uploaded file",
+ "defaultModel": {
+ "title": "Default Model",
+ "chatModel": "Default Chat Model",
+ "visionModel": "Default Vision Model"
+ }
},
"notificationsHooks": {
"title": "Notifications & Hooks",
@@ -795,7 +800,7 @@
"parseAndContinue": "Parse & Continue",
"jsonParseError": "JSON parsing failed",
"browseMarketplace": "Browse MCP Marketplace",
- "imageModel": "Choose a vision model",
+ "imageModel": "Vision Model",
"customHeadersParseError": "Custom Header parsing failed",
"customHeaders": "Custom Request Headers",
"clickToEdit": "Click to edit and view full content",
diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json
index 23de2477a..78331f0fe 100644
--- a/src/renderer/src/i18n/fa-IR/settings.json
+++ b/src/renderer/src/i18n/fa-IR/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "هنگامی که دیپچت در پیشزمینه نیست، اگر نشستی تولید شود، یک آگاهساز سامانه فرستاده خواهد شد",
"traceDebugEnabled": "پیگیری تماس",
"fileMaxSize": "حداکثر اندازه فایل",
- "fileMaxSizeHint": "حداکثر اندازه یک فایل قابل آپلود را محدود میکند"
+ "fileMaxSizeHint": "حداکثر اندازه یک فایل قابل آپلود را محدود میکند",
+ "defaultModel": {
+ "title": "مدل پیشفرض",
+ "chatModel": "مدل گفتگوی پیشفرض",
+ "visionModel": "مدل بینایی پیشفرض"
+ }
},
"notificationsHooks": {
"title": "اعلانها و هوکها",
@@ -795,7 +800,7 @@
"parseAndContinue": "تجزیه و ادامه",
"jsonParseError": "تجزیه JSON ناموفق بود",
"browseMarketplace": "مرور بازار MCP",
- "imageModel": "انتخاب مدل بصری",
+ "imageModel": "مدل بصری",
"customHeadersParseError": "تجزیه سربرگ دلخواه ناموفق بود",
"customHeaders": "سربرگ درخواست دلخواه",
"invalidKeyValueFormat": "قالب سربرگ درخواست نادرست است، لطفاً بررسی کنید که ورودی صحیح باشد.",
diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json
index 91130b8f2..b8777673c 100644
--- a/src/renderer/src/i18n/fr-FR/settings.json
+++ b/src/renderer/src/i18n/fr-FR/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "Quand DeepChat est en arrière‑plan, envoyer une notification à la fin d’une réponse",
"traceDebugEnabled": "Suivi des appels",
"fileMaxSize": "Taille maximale du fichier",
- "fileMaxSizeHint": "Limite la taille maximale d'un fichier à télécharger"
+ "fileMaxSizeHint": "Limite la taille maximale d'un fichier à télécharger",
+ "defaultModel": {
+ "title": "Modèle par défaut",
+ "chatModel": "Modèle de chat par défaut",
+ "visionModel": "Modèle vision par défaut"
+ }
},
"notificationsHooks": {
"title": "Notifications et hooks",
@@ -787,7 +792,7 @@
"jsonParseError": "Échec de l'analyse JSON",
"typeHttp": "Requêtes HTTP en streaming (HTTP)",
"browseMarketplace": "Allez sur MCP Market et installez-le en un seul clic",
- "imageModel": "Sélectionnez un modèle visuel",
+ "imageModel": "Modèle visuel",
"customHeadersParseError": "L'analyse de l'en-tête personnalisée a échoué",
"customHeaders": "En-tête de demande personnalisé",
"invalidKeyValueFormat": "Format d'en-tête de demande incorrect, veuillez vérifier si l'entrée est correcte.",
diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json
index 13da3fe5e..a88a768c7 100644
--- a/src/renderer/src/i18n/he-IL/settings.json
+++ b/src/renderer/src/i18n/he-IL/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "כאשר DeepChat אינו בחזית, אם נוצרת תשובה, תישלח התראת מערכת",
"contentProtection": "הגנה מפני לכידת מסך",
"fileMaxSize": "גודל קובץ מקסימלי",
- "fileMaxSizeHint": "מגביל את הגודל המקסימלי של קובץ בודד המועלה"
+ "fileMaxSizeHint": "מגביל את הגודל המקסימלי של קובץ בודד המועלה",
+ "defaultModel": {
+ "title": "מודל ברירת מחדל",
+ "chatModel": "מודל צ'אט ברירת מחדל",
+ "visionModel": "מודל ראייה ברירת מחדל"
+ }
},
"notificationsHooks": {
"title": "התראות ו‑Hooks",
@@ -795,7 +800,7 @@
"parseAndContinue": "פענח והמשך",
"jsonParseError": "פענוח JSON נכשל",
"browseMarketplace": "עיין בחנות MCP",
- "imageModel": "בחר מודל ראייה",
+ "imageModel": "מודל ראייה",
"customHeadersParseError": "פענוח כותרות מותאמות אישית נכשל",
"customHeaders": "כותרות בקשה מותאמות אישית",
"clickToEdit": "לחץ לעריכה וצפייה בתוכן המלא",
diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json
index 229769b39..d4fb92832 100644
--- a/src/renderer/src/i18n/ja-JP/settings.json
+++ b/src/renderer/src/i18n/ja-JP/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "DeepChatがバックグラウンドのとき、応答が完了すると通知を送信します",
"traceDebugEnabled": "追跡呼び出し",
"fileMaxSize": "ファイルの最大サイズ",
- "fileMaxSizeHint": "アップロードできるファイルの最大サイズを制限します"
+ "fileMaxSizeHint": "アップロードできるファイルの最大サイズを制限します",
+ "defaultModel": {
+ "title": "デフォルトモデル",
+ "chatModel": "デフォルトチャットモデル",
+ "visionModel": "デフォルト視覚モデル"
+ }
},
"notificationsHooks": {
"title": "通知とフック",
@@ -787,7 +792,7 @@
"typeHttp": "ストリーミングHTTPリクエスト(HTTP)",
"typeInMemory": "メモリ",
"browseMarketplace": "MCPサービス市場を閲覧します",
- "imageModel": "視覚モデルを選択します",
+ "imageModel": "視覚モデル",
"customHeadersParseError": "カスタムヘッダーの解析は失敗しました",
"customHeaders": "カスタムリクエストヘッダー",
"clickToEdit": "クリックして編集し、完全な内容を表示",
diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json
index bf1ed4fa1..a04ccec05 100644
--- a/src/renderer/src/i18n/ko-KR/settings.json
+++ b/src/renderer/src/i18n/ko-KR/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "DeepChat이 전경에 있지 않으면 세션이 생성되면 시스템 알림이 전송됩니다.",
"traceDebugEnabled": "추적 호출",
"fileMaxSize": "파일 최대 크기",
- "fileMaxSizeHint": "단일 파일 업로드의 최대 크기를 제한합니다"
+ "fileMaxSizeHint": "단일 파일 업로드의 최대 크기를 제한합니다",
+ "defaultModel": {
+ "title": "기본 모델",
+ "chatModel": "기본 채팅 모델",
+ "visionModel": "기본 비전 모델"
+ }
},
"notificationsHooks": {
"title": "알림 및 훅",
@@ -787,7 +792,7 @@
"jsonParseError": "JSON 파싱이 실패했습니다",
"typeHttp": "스트리밍 HTTP 요청 (HTTP)",
"browseMarketplace": "MCP 서비스 시장을 탐색하십시오",
- "imageModel": "시각적 모델을 선택하십시오",
+ "imageModel": "시각적 모델",
"customHeadersParseError": "사용자 정의 헤더 구문 분석이 실패했습니다",
"customHeaders": "사용자 정의 요청 헤더",
"invalidKeyValueFormat": "잘못된 요청 헤더 형식, 입력이 올바른지 확인하십시오.",
diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json
index 12c6b148c..fcc5e4b4c 100644
--- a/src/renderer/src/i18n/pt-BR/settings.json
+++ b/src/renderer/src/i18n/pt-BR/settings.json
@@ -59,7 +59,12 @@
"contentProtection": "Proteção contra captura de tela",
"traceDebugEnabled": "Rastreamento de Chamadas",
"fileMaxSize": "Tamanho máximo do arquivo",
- "fileMaxSizeHint": "Limita o tamanho máximo de um arquivo enviado"
+ "fileMaxSizeHint": "Limita o tamanho máximo de um arquivo enviado",
+ "defaultModel": {
+ "title": "Modelo padrão",
+ "chatModel": "Modelo de chat padrão",
+ "visionModel": "Modelo de visão padrão"
+ }
},
"notificationsHooks": {
"title": "Notificações e hooks",
@@ -795,7 +800,7 @@
"parseAndContinue": "Analisar e Continuar",
"jsonParseError": "Falha na análise do JSON",
"browseMarketplace": "Navegar no Mercado MCP",
- "imageModel": "Escolha um modelo de visão",
+ "imageModel": "Modelo de visão",
"customHeadersParseError": "Falha na análise do Cabeçalho Personalizado",
"customHeaders": "Cabeçalhos de Solicitação Personalizados",
"clickToEdit": "Clique para editar e visualizar o conteúdo completo",
diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json
index 4ff0b12d4..2a95a6f14 100644
--- a/src/renderer/src/i18n/ru-RU/settings.json
+++ b/src/renderer/src/i18n/ru-RU/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "Когда DeepChat не будет на переднем плане, если сгенерируется сеанс, будет отправлено системное уведомление",
"traceDebugEnabled": "Функция отладки Trace",
"fileMaxSize": "Максимальный размер файла",
- "fileMaxSizeHint": "Ограничивает максимальный размер загружаемого файла"
+ "fileMaxSizeHint": "Ограничивает максимальный размер загружаемого файла",
+ "defaultModel": {
+ "title": "Модель по умолчанию",
+ "chatModel": "Модель чата по умолчанию",
+ "visionModel": "Визуальная модель по умолчанию"
+ }
},
"notificationsHooks": {
"title": "Уведомления и хуки",
@@ -787,7 +792,7 @@
"jsonParseError": "JSON SAINING не удалось",
"typeHttp": "Потоковые http -запросы (http)",
"browseMarketplace": "Просмотреть рынок услуг MCP",
- "imageModel": "Выберите визуальную модель",
+ "imageModel": "Визуальная модель",
"customHeadersParseError": "Пользовательский диапазон заголовка не удалось",
"customHeaders": "Пользовательский заголовок запроса",
"invalidKeyValueFormat": "Неправильный формат заголовка запроса, пожалуйста, проверьте, является ли вход правильным.",
diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json
index bee77a6f6..c55a4e151 100644
--- a/src/renderer/src/i18n/zh-CN/settings.json
+++ b/src/renderer/src/i18n/zh-CN/settings.json
@@ -59,7 +59,12 @@
"notifications": "系统通知",
"notificationsDesc": "当 DeepChat 不在前台时,如有会话生成完毕会发送系统通知",
"fileMaxSize": "文件最大大小",
- "fileMaxSizeHint": "限制单个文件的最大上传大小"
+ "fileMaxSizeHint": "限制单个文件的最大上传大小",
+ "defaultModel": {
+ "title": "默认模型",
+ "chatModel": "默认聊天模型",
+ "visionModel": "默认视觉模型"
+ }
},
"notificationsHooks": {
"title": "通知与Hooks",
@@ -794,7 +799,7 @@
"parseAndContinue": "解析并继续",
"jsonParseError": "JSON解析失败",
"browseMarketplace": "浏览MCP服务市场",
- "imageModel": "选择视觉模型",
+ "imageModel": "视觉模型",
"customHeadersParseError": "自定义Header解析失败",
"customHeaders": "自定义请求头",
"clickToEdit": "点击编辑以查看完整内容",
diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json
index 24158e956..c17a018a2 100644
--- a/src/renderer/src/i18n/zh-HK/settings.json
+++ b/src/renderer/src/i18n/zh-HK/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "當 DeepChat 不在前台時,如有會話生成完畢會發送系統通知",
"traceDebugEnabled": "Trace 除錯功能",
"fileMaxSize": "檔案最大大小",
- "fileMaxSizeHint": "限制單個檔案的最大上傳大小"
+ "fileMaxSizeHint": "限制單個檔案的最大上傳大小",
+ "defaultModel": {
+ "title": "預設模型",
+ "chatModel": "預設聊天模型",
+ "visionModel": "預設視覺模型"
+ }
},
"notificationsHooks": {
"title": "通知與 Hooks",
@@ -787,7 +792,7 @@
"jsonParseError": "JSON解析失敗",
"typeHttp": "可流式傳輸的HTTP請求(HTTP)",
"browseMarketplace": "瀏覽MCP服務市場",
- "imageModel": "選擇視覺模型",
+ "imageModel": "視覺模型",
"customHeadersParseError": "自定義Header解析失敗",
"customHeaders": "自定義請求頭",
"invalidKeyValueFormat": "錯誤的請求頭格式,請檢查輸入是否正確",
diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json
index bd7f2e71f..ec4f004c8 100644
--- a/src/renderer/src/i18n/zh-TW/settings.json
+++ b/src/renderer/src/i18n/zh-TW/settings.json
@@ -59,7 +59,12 @@
"notificationsDesc": "當 DeepChat 不在前台時,如有會話生成完畢會發送系統通知",
"traceDebugEnabled": "Trace 除錯功能",
"fileMaxSize": "檔案最大大小",
- "fileMaxSizeHint": "限制單個檔案的最大上傳大小"
+ "fileMaxSizeHint": "限制單個檔案的最大上傳大小",
+ "defaultModel": {
+ "title": "預設模型",
+ "chatModel": "預設聊天模型",
+ "visionModel": "預設視覺模型"
+ }
},
"notificationsHooks": {
"title": "通知與 Hooks",
@@ -787,7 +792,7 @@
"typeHttp": "可流式傳輸的HTTP請求(HTTP)",
"typeInMemory": "內存(InMemory)",
"browseMarketplace": "瀏覽MCP服務市場",
- "imageModel": "選擇視覺模型",
+ "imageModel": "視覺模型",
"customHeadersParseError": "自定義Header解析失敗",
"customHeaders": "自定義請求頭",
"invalidKeyValueFormat": "錯誤的請求頭格式,請檢查輸入是否正確",
diff --git a/src/renderer/src/pages/ChatPage.vue b/src/renderer/src/pages/ChatPage.vue
new file mode 100644
index 000000000..c4e6e0166
--- /dev/null
+++ b/src/renderer/src/pages/ChatPage.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue
new file mode 100644
index 000000000..3f6810c5b
--- /dev/null
+++ b/src/renderer/src/pages/NewThreadPage.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+

+
+
+
+
Build and explore
+
+
+
+
+
+
+ {{ projectStore.selectedProjectName }}
+
+
+
+
+ Recent Projects
+
+
+
+ {{ project.name }}
+ {{ project.path }}
+
+
+
+
+
+ Open folder...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/pages/WelcomePage.vue b/src/renderer/src/pages/WelcomePage.vue
new file mode 100644
index 000000000..c56e1ec95
--- /dev/null
+++ b/src/renderer/src/pages/WelcomePage.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+

+
+
+
+
Welcome to DeepChat Agent
+
+ Connect a model provider to start build
+
+
+
+
+
+
+ {{ provider.name }}
+
+
+
+
+ Browse all providers...
+
+
+
+
+
+
+
or connect an agent
+
+
+
+
+
+
+
+
+
Set up an ACP agent
+
Claude Code, Codex, Kimi, or your own
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/stores/ui/agent.ts b/src/renderer/src/stores/ui/agent.ts
new file mode 100644
index 000000000..9103a5bbb
--- /dev/null
+++ b/src/renderer/src/stores/ui/agent.ts
@@ -0,0 +1,66 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { usePresenter } from '@/composables/usePresenter'
+import type { Agent } from '@shared/types/agent-interface'
+
+// --- Type Definitions ---
+
+export interface UIAgent {
+ id: string
+ name: string
+ type: 'deepchat' | 'acp'
+ enabled: boolean
+}
+
+// --- Store ---
+
+export const useAgentStore = defineStore('agent', () => {
+ const newAgentPresenter = usePresenter('newAgentPresenter')
+
+ // --- 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 {
+ loading.value = true
+ error.value = null
+ try {
+ const result: Agent[] = await newAgentPresenter.getAgents()
+ agents.value = result.map((a) => ({
+ id: a.id,
+ name: a.name,
+ type: a.type,
+ enabled: a.enabled
+ }))
+ } catch (e) {
+ error.value = `Failed to load agents: ${e}`
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function selectAgent(id: string | null): void {
+ selectedAgentId.value = selectedAgentId.value === id ? null : id
+ }
+
+ return {
+ agents,
+ selectedAgentId,
+ loading,
+ error,
+ enabledAgents,
+ selectedAgent,
+ selectedAgentName,
+ fetchAgents,
+ selectAgent
+ }
+})
diff --git a/src/renderer/src/stores/ui/draft.ts b/src/renderer/src/stores/ui/draft.ts
new file mode 100644
index 000000000..dbff1cfc3
--- /dev/null
+++ b/src/renderer/src/stores/ui/draft.ts
@@ -0,0 +1,44 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import type { CreateSessionInput } from '@shared/types/agent-interface'
+
+// --- Store ---
+
+export const useDraftStore = defineStore('draft', () => {
+ // --- State ---
+ const providerId = ref(undefined)
+ const modelId = ref(undefined)
+ const projectDir = ref(undefined)
+ const agentId = ref('deepchat')
+ const reasoningEffort = ref(undefined)
+
+ // --- Actions ---
+
+ function toCreateInput(message: string): CreateSessionInput {
+ return {
+ agentId: agentId.value,
+ message,
+ projectDir: projectDir.value,
+ providerId: providerId.value,
+ modelId: modelId.value
+ }
+ }
+
+ function reset(): void {
+ providerId.value = undefined
+ modelId.value = undefined
+ projectDir.value = undefined
+ agentId.value = 'deepchat'
+ reasoningEffort.value = undefined
+ }
+
+ return {
+ providerId,
+ modelId,
+ projectDir,
+ agentId,
+ reasoningEffort,
+ toCreateInput,
+ reset
+ }
+})
diff --git a/src/renderer/src/stores/ui/message.ts b/src/renderer/src/stores/ui/message.ts
new file mode 100644
index 000000000..47c037879
--- /dev/null
+++ b/src/renderer/src/stores/ui/message.ts
@@ -0,0 +1,144 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { usePresenter } from '@/composables/usePresenter'
+import { STREAM_EVENTS } from '@/events'
+import type { ChatMessageRecord, AssistantMessageBlock } from '@shared/types/agent-interface'
+import { useSessionStore } from './session'
+
+// --- Store ---
+
+export const useMessageStore = defineStore('message', () => {
+ const newAgentPresenter = usePresenter('newAgentPresenter')
+
+ // --- State ---
+ const messageIds = ref([])
+ const messageCache = ref