default model
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
# Chat with Pi - Flow Documentation
|
||||
|
||||
This document describes the full-stack architecture and data flow of the Chat app in Officer.dev, which uses **Pi** (an AI coding agent) as the backend.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND │
|
||||
│ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ │
|
||||
│ │ InputArea │───▶│ usePiChat │───▶│ useChatWebSocket│ │
|
||||
│ │ (user input) │ │ (hook) │ │ (WS connection)│ │
|
||||
│ └──────────────┘ └───────┬────────┘ └───────┬────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌────────────────┐ ┌───────────────┐ │
|
||||
│ │ MessageList │◀───│ handleMessage │ │
|
||||
│ │ (UI updates) │ │ (event parser)│ │
|
||||
│ └────────────────┘ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ server.tsx │ │
|
||||
│ │ '/api/pi/chat/ws' → upgradeWs() → piWebsocket handler │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ websocket.ts (piWebsocket) │ │
|
||||
│ │ │ │
|
||||
│ │ message() ──┬──▶ handleChat() ──▶ SessionManager │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ │ pi-bridge.ts │ │
|
||||
│ │ │ (spawn Pi subprocess) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ │ Bun.spawn(['pi', '--mode', │ │
|
||||
│ │ │ 'rpc', ...]) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ │ Parse JSON from stdout │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ └────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ sendToClient() ──▶ WebSocket JSON │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ storage.ts │ │
|
||||
│ │ Save to: ~/.pi-sessions/<sessionId>/ │ │
|
||||
│ │ - meta.json (session metadata) │ │
|
||||
│ │ - messages.json (message history) │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│
|
||||
Pi subprocess
|
||||
(AI Agent)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Frontend
|
||||
|
||||
```
|
||||
src/workspaces/officerdev/src/
|
||||
├── apps/
|
||||
│ ├── Chat/
|
||||
│ │ ├── index.ts # Main entry, exports
|
||||
│ │ ├── types.ts # TypeScript types
|
||||
│ │ ├── ChatPanelWrapper.tsx # Main wrapper component
|
||||
│ │ ├── ChatList.tsx # Session list sidebar
|
||||
│ │ ├── ChatLauncher.tsx # Launch chat
|
||||
│ │ ├── EmbeddableChat.tsx # Embeddable version
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── InputArea.tsx # User input
|
||||
│ │ │ ├── MessageList.tsx # Message display
|
||||
│ │ │ ├── MessageBubble.tsx # Individual message
|
||||
│ │ │ ├── ToolActivity.tsx # Tool execution UI
|
||||
│ │ │ ├── QuestionActivity.tsx # Question prompt UI
|
||||
│ │ │ ├── ModelSelector.tsx # Model dropdown
|
||||
│ │ │ ├── AttachButton.tsx # File attachment
|
||||
│ │ │ ├── AttachmentList.tsx # Attached files
|
||||
│ │ │ └── WebpageDialog.tsx # URL preview
|
||||
│ │ ├── hooks/
|
||||
│ │ │ └── useChat.ts # Main chat hook
|
||||
│ │ ├── useAttachments.ts # File attachment handling
|
||||
│ │ ├── useAudioRecording.ts # Voice input
|
||||
│ │ └── useSlashCommands.ts # /commands handling
|
||||
│ │
|
||||
│ └── ChatHistory/ # Session management UI
|
||||
│
|
||||
├── hooks/
|
||||
│ ├── usePiChat.ts # Main chat hook
|
||||
│ └── ...
|
||||
│
|
||||
└── state/
|
||||
└── useChatSessions.ts # Session state management
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```
|
||||
src/
|
||||
├── server.tsx # Main server entry
|
||||
│ # - WebSocket upgrade
|
||||
│ # - Route registration
|
||||
│ # - Pi installation check
|
||||
│
|
||||
└── servers/
|
||||
├── hono.ts # Hono REST server
|
||||
│
|
||||
└── api/
|
||||
├── pi/
|
||||
│ ├── types.ts # TypeScript types
|
||||
│ ├── websocket.ts # WebSocket handler
|
||||
│ ├── session-manager.ts # Session lifecycle
|
||||
│ ├── pi-bridge.ts # Pi subprocess spawner
|
||||
│ ├── storage.ts # Disk persistence
|
||||
│ ├── rest.ts # REST API endpoints
|
||||
│ ├── logger.ts # Logging
|
||||
│ └── README.md # Original Pi API docs
|
||||
│
|
||||
└── ... (other API routes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### Client Messages (Frontend → Backend)
|
||||
|
||||
```typescript
|
||||
// Start new chat
|
||||
type ClientMessage =
|
||||
| { type: 'chat'; prompt: string; sessionId?: string; model?: string; cwd?: string; groupSlug?: string; attachmentIds?: string[] }
|
||||
| { type: 'resume'; sessionId: string } // Resume existing session
|
||||
| { type: 'stop' }; // Stop generation
|
||||
```
|
||||
|
||||
### Server Messages (Backend → Frontend)
|
||||
|
||||
```typescript
|
||||
type ServerMessage =
|
||||
| { type: 'session:init'; sessionId: string; model: string; cwd: string }
|
||||
| { type: 'assistant:delta'; text: string } // Streaming token
|
||||
| { type: 'assistant:text'; text: string } // Complete message
|
||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
|
||||
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
|
||||
| { type: 'result'; sessionId: string; cost: MessageCost }
|
||||
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
|
||||
| { type: 'error'; message: string; errorCode?: string }
|
||||
| { type: 'stopped' };
|
||||
```
|
||||
|
||||
### Internal Types
|
||||
|
||||
```typescript
|
||||
type Message = {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
text?: string;
|
||||
model?: string;
|
||||
cost?: MessageCost;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type SessionMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
cwd: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
cost: MessageCost;
|
||||
groupSlug?: string | null;
|
||||
};
|
||||
|
||||
type MessageCost = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalUSD: number;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/pi/models` | List available AI models |
|
||||
| POST | `/api/pi/sessions` | List all sessions |
|
||||
| GET | `/api/pi/sessions/:id` | Get session with messages |
|
||||
| PATCH | `/api/pi/sessions/:id` | Update session (rename) |
|
||||
| DELETE | `/api/pi/sessions/:id` | Delete session |
|
||||
| GET | `/api/pi/sessions/search?q=` | Search sessions |
|
||||
| POST | `/api/pi/groups` | Create group |
|
||||
| GET | `/api/pi/groups` | List groups |
|
||||
| PATCH | `/api/pi/groups/:slug` | Update group |
|
||||
| DELETE | `/api/pi/groups/:slug` | Delete group |
|
||||
| POST | `/api/pi/sessions/:id/move` | Move session between groups |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Details
|
||||
|
||||
### 1. Starting a New Chat
|
||||
|
||||
```
|
||||
User enters message
|
||||
↓
|
||||
usePiChat.sendPrompt()
|
||||
↓
|
||||
useChatWebSocket.send({ type: 'chat', prompt, ... })
|
||||
↓
|
||||
WebSocket connection to /api/pi/chat/ws
|
||||
↓
|
||||
server.tsx: upgradeWs() - JWT verification
|
||||
↓
|
||||
websocket.ts: handleChat()
|
||||
↓
|
||||
SessionManager.getOrCreate(sessionId, email, cwd, model)
|
||||
↓
|
||||
pi-bridge.ts: spawnPi(cwd, model, onEvent)
|
||||
↓
|
||||
Bun.spawn(['pi', '--mode', 'rpc', '--no-extensions', ...])
|
||||
↓
|
||||
send prompt to Pi via stdin
|
||||
↓
|
||||
Pi process streams JSON events to stdout
|
||||
↓
|
||||
pi-bridge parses events → onEvent(PiEvent)
|
||||
↓
|
||||
websocket.ts createEventHandler() → sendToClient()
|
||||
↓
|
||||
useChatWebSocket.onMessage() → usePiChat.handleMessage()
|
||||
↓
|
||||
React state update → UI renders
|
||||
```
|
||||
|
||||
### 2. Resuming a Session
|
||||
|
||||
```
|
||||
User navigates to /chat/:sessionId
|
||||
↓
|
||||
usePiChat mounts (with initialSessionId)
|
||||
↓
|
||||
useEffect triggers: getSession(initialSessionId)
|
||||
↓
|
||||
REST API: GET /api/pi/sessions/:sessionId
|
||||
↓
|
||||
storage.ts: loadSession() reads from disk
|
||||
↓
|
||||
Messages loaded into state
|
||||
↓
|
||||
WebSocket connects (for new messages)
|
||||
↓
|
||||
websocket.ts: handleResume()
|
||||
↓
|
||||
Spawns fresh Pi process
|
||||
↓
|
||||
Sends 'sync:messages' to client
|
||||
```
|
||||
|
||||
### 3. Pi Event Parsing
|
||||
|
||||
```
|
||||
Pi stdout JSON line:
|
||||
{
|
||||
"type": "message_update",
|
||||
"assistantMessageEvent": {
|
||||
"type": "text_delta",
|
||||
"delta": "Hello"
|
||||
}
|
||||
}
|
||||
↓
|
||||
pi-bridge.ts: parsePiEvent()
|
||||
↓
|
||||
Converted to PiEvent:
|
||||
{ type: 'delta', text: 'Hello' }
|
||||
↓
|
||||
Event handler:
|
||||
- 'delta' → append to streamBuffer, send 'assistant:delta'
|
||||
- 'text' → commit streamBuffer, send 'assistant:text'
|
||||
- 'tool:start' → send 'tool:start', add tool message
|
||||
- 'tool:result' → send 'tool:result', update tool message
|
||||
- 'result' → save session, send 'result'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Storage
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
~/.pi-sessions/
|
||||
├── <sessionId>/
|
||||
│ ├── meta.json # Session metadata
|
||||
│ └── messages.json # Message history
|
||||
│
|
||||
└── @<groupSlug>/ # Grouped sessions
|
||||
├── .group-meta.json
|
||||
├── <sessionId1>/
|
||||
│ ├── meta.json
|
||||
│ └── messages.json
|
||||
└── <sessionId2>/
|
||||
```
|
||||
|
||||
### meta.json
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "session-uuid",
|
||||
"title": "First prompt...",
|
||||
"model": "opencode/big-pickle",
|
||||
"cwd": "/home/user",
|
||||
"createdAt": 1700000000000,
|
||||
"updatedAt": 1700000000000,
|
||||
"messageCount": 10,
|
||||
"cost": {
|
||||
"inputTokens": 5000,
|
||||
"outputTokens": 2000,
|
||||
"totalUSD": 0.05
|
||||
},
|
||||
"groupSlug": null
|
||||
}
|
||||
```
|
||||
|
||||
### messages.json
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{ "id": "...", "timestamp": 1700000000000, "role": "user", "text": "Hello" },
|
||||
{ "id": "...", "timestamp": 1700000000001, "role": "assistant", "text": "Hi!" },
|
||||
{ "id": "...", "timestamp": 1700000000002, "role": "tool", "toolName": "Bash", "toolInput": {...}, "output": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### WebSocket Authentication
|
||||
|
||||
1. Client connects: `ws://host/api/pi/chat/ws?token=<JWT>`
|
||||
2. `server.tsx:upgradeWs()` extracts token from URL
|
||||
3. `jwt.ts:verify(token)` validates JWT
|
||||
4. Check token blacklist (logout/revocation)
|
||||
5. Extract user info: `{ userId, email, role }`
|
||||
6. Attach to WebSocket data: `ws.data = { userId, email, provider: 'pi', ... }`
|
||||
|
||||
### REST Authentication
|
||||
|
||||
- Bearer token in `Authorization` header
|
||||
- `hono.ts` uses `userMiddleware` to verify
|
||||
- User object attached to request context: `ctx.get('user')`
|
||||
|
||||
---
|
||||
|
||||
## Session Management
|
||||
|
||||
### SessionManager (in-memory)
|
||||
|
||||
```typescript
|
||||
class SessionManager {
|
||||
private sessions = new Map<string, UserSession>();
|
||||
private userSessions = new Map<string, string[]>(); // email → sessionIds
|
||||
|
||||
getOrCreate(sessionId, email, cwd, model, groupSlug): UserSession
|
||||
getSession(sessionId): UserSession | null
|
||||
attachWs(sessionId, ws): void
|
||||
detachWs(sessionId): void
|
||||
setIdleTimeout(sessionId, timeoutMs): void // Auto-cleanup after 1 hour
|
||||
deleteSession(sessionId): void
|
||||
}
|
||||
```
|
||||
|
||||
### UserSession Object
|
||||
|
||||
```typescript
|
||||
type UserSession = {
|
||||
sessionId: string;
|
||||
email: string;
|
||||
cwd: string;
|
||||
model: string;
|
||||
piProcess: Subprocess | null; // Bun subprocess
|
||||
ws: WebSocket | null; // Connected client
|
||||
lastActivity: number;
|
||||
idleTimer: Timer | null;
|
||||
streamBuffer: string; // Uncommitted streaming text
|
||||
isGenerating: boolean;
|
||||
systemContextSent: boolean;
|
||||
messages: Message[];
|
||||
meta: SessionMeta;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pi Subprocess
|
||||
|
||||
### Spawn Command
|
||||
|
||||
```typescript
|
||||
const args = [
|
||||
'pi',
|
||||
'--mode', 'rpc',
|
||||
'--no-extensions',
|
||||
'--no-skills',
|
||||
'--no-prompt-templates',
|
||||
'--no-themes',
|
||||
'--model', model // e.g., 'opencode/big-pickle'
|
||||
];
|
||||
|
||||
Bun.spawn(args, {
|
||||
cwd,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe', // JSON event stream
|
||||
stderr: 'pipe', // Debug logs
|
||||
});
|
||||
```
|
||||
|
||||
### RPC Protocol
|
||||
|
||||
```
|
||||
// Send prompt (stdin):
|
||||
{ "type": "prompt", "id": "request-uuid", "message": "Hello" }
|
||||
|
||||
{ "type": "abort", "id": "request-uuid" }
|
||||
|
||||
// Receive events (stdout):
|
||||
{ "type": "message_update", "assistantMessageEvent": { "type": "text_delta", "delta": "..." } }
|
||||
{ "type": "message_end" }
|
||||
{ "type": "tool_execution_start", "toolCallId": "...", "toolName": "Bash", "args": {...} }
|
||||
{ "type": "tool_execution_end", "toolCallId": "...", "result": "...", "isError": false }
|
||||
{ "type": "agent_end", "cost": {...} }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server.tsx` | Main server, WebSocket upgrade, route setup |
|
||||
| `servers/api/pi/websocket.ts` | WebSocket message handling |
|
||||
| `servers/api/pi/session-manager.ts` | Session lifecycle in memory |
|
||||
| `servers/api/pi/pi-bridge.ts` | Spawn and communicate with Pi |
|
||||
| `servers/api/pi/storage.ts` | Disk persistence |
|
||||
| `servers/api/pi/rest.ts` | REST API endpoints |
|
||||
| `servers/api/pi/types.ts` | Shared types |
|
||||
| `workspaces/officerdev/src/hooks/usePiChat.ts` | Main frontend hook |
|
||||
| `workspaces/hooks/src/useChatWebSocket.ts` | WebSocket connection |
|
||||
| `workspaces/officerdev/src/apps/Chat/types.ts` | Frontend types |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `PORT` - Server port (default: 5000)
|
||||
- `JWT_SECRET` - JWT signing secret
|
||||
|
||||
### Constants
|
||||
|
||||
```typescript
|
||||
// websocket.ts
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// storage.ts
|
||||
const PI_SESSIONS_DIR = '.pi-sessions';
|
||||
const GROUP_PREFIX = '@';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- WebSocket errors → Logged and sent as `{ type: 'error', message: ... }`
|
||||
- Pi process errors → `{ type: 'error', message: errorMsg }`
|
||||
- Session not found → HTTP 404 or `SESSION_NOT_FOUND` error code
|
||||
- Token blacklist check → 401 Unauthorized
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Backend
|
||||
- `bun` - Runtime
|
||||
- `hono` - HTTP framework
|
||||
- `drizzle-orm` - Database (for user auth)
|
||||
|
||||
### Frontend
|
||||
- `react` - UI framework
|
||||
- `@tanstack/react-query` - Data fetching
|
||||
- `lucide-react` - Icons
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2024*
|
||||
@@ -0,0 +1,46 @@
|
||||
## Authentication
|
||||
|
||||
src/apps/officer-web/Screens/Authentication/ForgotPassword/useResetPassword.ts
|
||||
src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts
|
||||
|
||||
## Files
|
||||
|
||||
src/apps/officer-web/Screens/Dashboard/Files/state/usePinnedFiles.ts
|
||||
src/apps/officer-web/Screens/Dashboard/Files/state/useRecentFiles.ts
|
||||
|
||||
## Officer-web State
|
||||
|
||||
src/apps/officer-web/state/useChatGroups.ts
|
||||
src/apps/officer-web/state/useChatSessions.ts
|
||||
src/apps/officer-web/state/useInitialData.ts
|
||||
src/apps/officer-web/state/useLandingPage.ts
|
||||
src/apps/officer-web/state/useModels.ts
|
||||
src/apps/officer-web/state/usePlans.ts
|
||||
src/apps/officer-web/state/useProjectsState.ts
|
||||
src/apps/officer-web/state/useRecentModels.ts
|
||||
src/apps/officer-web/state/useResources.ts
|
||||
src/apps/officer-web/state/useServerSettings.ts
|
||||
src/apps/officer-web/state/useSettings.ts
|
||||
src/apps/officer-web/state/useThemeSync.ts
|
||||
src/apps/officer-web/state/useUserState.ts
|
||||
src/apps/officer-web/state/useWorkspacesState.ts
|
||||
|
||||
## Chat (apps/Chat)
|
||||
|
||||
src/workspaces/apps/Chat/useChatSessions.ts
|
||||
src/workspaces/apps/Chat/useChatSession.ts
|
||||
src/workspaces/apps/Chat/usePi.ts
|
||||
src/workspaces/apps/Chat/useSlashCommands.ts
|
||||
|
||||
## Other Workspaces
|
||||
|
||||
src/workspaces/apps/CodeEditor/useEditorState.ts
|
||||
src/workspaces/apps/FileBrowser/useFiles.ts
|
||||
src/workspaces/apps/FileBrowser/useTasks.ts
|
||||
src/workspaces/components/DataTable/useFixedHeightPagination.ts
|
||||
src/workspaces/components/ui/hooks/use-mobile.tsx
|
||||
src/workspaces/components/ui/hooks/use-toast.ts
|
||||
src/workspaces/components/ui/use-toast.ts
|
||||
src/workspaces/i18n/src/useTranslation.ts
|
||||
src/workspaces/injector/use-client.ts
|
||||
Done!
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,26 @@ import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
|
||||
import { sessionManager } from './session-manager';
|
||||
import * as storage from './storage';
|
||||
import * as piBridge from './pi-bridge';
|
||||
import { getHomeDir } from '../../../servers/data-path';
|
||||
import { getHomeDir, getUserSettingsFile } from '../../../servers/data-path';
|
||||
import { logger } from './logger';
|
||||
|
||||
// Default model when no user preference is set
|
||||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||
|
||||
async function getUserDefaultModel(email: string): Promise<string | null> {
|
||||
try {
|
||||
const settingsPath = getUserSettingsFile(email);
|
||||
const file = Bun.file(settingsPath);
|
||||
if (await file.exists()) {
|
||||
const settings = await file.json();
|
||||
return settings?.chat?.defaultModel || null;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to read user settings for default model', { email, error: String(err) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
@@ -25,7 +42,7 @@ function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage): v
|
||||
}
|
||||
|
||||
export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
logger.info('WebSocket connection opened', { email: ws.data.email });
|
||||
// logger.info('WebSocket connection opened', { email: ws.data.email });
|
||||
}
|
||||
|
||||
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void {
|
||||
@@ -50,7 +67,7 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
|
||||
}
|
||||
|
||||
export function close(ws: ServerWebSocket<WSData>): void {
|
||||
logger.info('WebSocket connection closed', { email: ws.data.email });
|
||||
// logger.info('WebSocket connection closed', { email: ws.data.email });
|
||||
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (sessionId) {
|
||||
@@ -207,7 +224,30 @@ async function handleChat(
|
||||
): Promise<void> {
|
||||
const { email } = ws.data;
|
||||
const sessionId = msg.sessionId || randomUUID();
|
||||
const model = msg.model || 'opencode/big-pickle';
|
||||
|
||||
// Use provided model, or fall back to user default, or use system default
|
||||
let model = msg.model;
|
||||
let modelSource = 'client-provided';
|
||||
let userDefault = null;
|
||||
if (!model) {
|
||||
userDefault = await getUserDefaultModel(email);
|
||||
if (userDefault) {
|
||||
model = userDefault;
|
||||
modelSource = 'user-settings';
|
||||
} else {
|
||||
model = DEFAULT_MODEL;
|
||||
modelSource = 'system-default';
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Model selected for chat', {
|
||||
sessionId,
|
||||
model,
|
||||
modelSource,
|
||||
clientModel: msg.model || null,
|
||||
userDefault,
|
||||
});
|
||||
|
||||
const cwd = msg.cwd || getHomeDir(email);
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export function ChatLauncher({
|
||||
model={selectedModel}
|
||||
isConnected={true}
|
||||
isGenerating={false}
|
||||
hasStarted={false}
|
||||
/>
|
||||
|
||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||
|
||||
@@ -35,6 +35,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) {
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
hasStarted,
|
||||
setSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
@@ -170,6 +171,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) {
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
hasStarted,
|
||||
setSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
model,
|
||||
hasStarted,
|
||||
attachments,
|
||||
attachWebpage,
|
||||
attachImage,
|
||||
@@ -94,6 +95,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
model={model}
|
||||
isConnected={isConnected}
|
||||
isGenerating={isGenerating}
|
||||
hasStarted={hasStarted}
|
||||
/>
|
||||
|
||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||
|
||||
@@ -27,6 +27,7 @@ type ModelSelectorProps = {
|
||||
model: string | null;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
hasStarted: boolean;
|
||||
};
|
||||
|
||||
export function ModelSelector({
|
||||
@@ -37,23 +38,40 @@ export function ModelSelector({
|
||||
model,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
hasStarted,
|
||||
}: ModelSelectorProps) {
|
||||
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
||||
|
||||
const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0];
|
||||
// Determine which model to display: selectedModel takes precedence, then model (from server), then fallback
|
||||
const displayModel = selectedModel || model;
|
||||
|
||||
const activeProvider = availableModels.find((m) => m.id === displayModel)?.provider ?? providers[0];
|
||||
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
|
||||
const fallbackModelId = providerModels[0]?.id ?? null;
|
||||
|
||||
// Lock after session has started
|
||||
const isLocked = hasStarted || isGenerating || !isConnected;
|
||||
|
||||
const handleProviderClick = (provider: string) => {
|
||||
if (isLocked) return;
|
||||
const firstModel = availableModels.find((m) => m.provider === provider);
|
||||
if (firstModel) onModelChange(firstModel.id);
|
||||
};
|
||||
|
||||
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
|
||||
|
||||
// Get display text for the model
|
||||
const getModelDisplayText = () => {
|
||||
if (displayModel) {
|
||||
const found = availableModels.find((m) => m.id === displayModel);
|
||||
return found?.name || displayModel;
|
||||
}
|
||||
return 'Select model';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
{messages.length > 0 ? (
|
||||
{isLocked ? (
|
||||
<span className="rounded-md bg-duck-dark/80 px-3 py-1 text-xs font-medium text-white">
|
||||
{activeProvider ? displayName(activeProvider) : 'Pi'}
|
||||
</span>
|
||||
@@ -77,9 +95,9 @@ export function ModelSelector({
|
||||
<div className="text-xs text-duck-dark/50">
|
||||
{providerModels.length > 0 ? (
|
||||
<Select
|
||||
value={selectedModel ?? fallbackModelId ?? undefined}
|
||||
onValueChange={(v) => onModelChange(v)}
|
||||
disabled={isGenerating || !isConnected}
|
||||
value={displayModel ?? fallbackModelId ?? undefined}
|
||||
onValueChange={(v) => !isLocked && onModelChange(v)}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<SelectTrigger className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer">
|
||||
<SelectValue />
|
||||
@@ -93,7 +111,7 @@ export function ModelSelector({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span>{model ?? 'Pi'}</span>
|
||||
<span>{getModelDisplayText()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
@@ -27,6 +28,19 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
|
||||
const [cwd, setCwd] = useState<string | null>(null);
|
||||
|
||||
// Track if session has started (first message sent)
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
|
||||
// Get user settings for default model
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Set default model from settings when starting a new chat (no initialSessionId, no initialModel)
|
||||
useEffect(() => {
|
||||
if (!initialSessionId && !initialModel && settings?.chat?.defaultModel) {
|
||||
setSelectedModel(settings.chat.defaultModel);
|
||||
}
|
||||
}, [initialSessionId, initialModel, settings]);
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
@@ -238,6 +252,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
||||
cwdParam?: { root?: string; path: string },
|
||||
groupSlug?: string | null,
|
||||
) {
|
||||
// Mark session as started on first message
|
||||
if (!hasStarted) {
|
||||
setHasStarted(true);
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
|
||||
setIsGenerating(true);
|
||||
streamingRef.current = '';
|
||||
@@ -277,6 +296,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
hasStarted,
|
||||
cwd,
|
||||
setSelectedModel,
|
||||
sendPrompt,
|
||||
|
||||
Reference in New Issue
Block a user