From 90891fd1db166aec0fadb00d590903271920e0d8 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 19 Aug 2025 17:49:32 -0700 Subject: [PATCH 01/29] add high-level arch for WaveAI feature --- aiprompts/waveai-architecture.md | 366 +++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 aiprompts/waveai-architecture.md diff --git a/aiprompts/waveai-architecture.md b/aiprompts/waveai-architecture.md new file mode 100644 index 0000000000..3e070fe750 --- /dev/null +++ b/aiprompts/waveai-architecture.md @@ -0,0 +1,366 @@ +# Wave AI Architecture Documentation + +## Overview + +Wave AI is a chat-based AI assistant feature integrated into Wave Terminal. It provides a conversational interface for interacting with various AI providers (OpenAI, Anthropic, Perplexity, Google, and Wave's cloud proxy) through a unified streaming architecture. The feature is implemented as a block view within Wave Terminal's modular system. + +## Architecture Components + +### Frontend Architecture (`frontend/app/view/waveai/`) + +#### Core Components + +**1. WaveAiModel Class** +- **Purpose**: Main view model implementing the `ViewModel` interface +- **Responsibilities**: + - State management using Jotai atoms + - Configuration management (presets, AI options) + - Message handling and persistence + - RPC communication with backend + - UI state coordination + +**2. AiWshClient Class** +- **Purpose**: Specialized WSH RPC client for AI operations +- **Extends**: `WshClient` +- **Responsibilities**: + - Handle incoming `aisendmessage` RPC calls + - Route messages to the model's `sendMessage` method + +**3. React Components** +- **WaveAi**: Main container component +- **ChatWindow**: Scrollable message display with auto-scroll behavior +- **ChatItem**: Individual message renderer with role-based styling +- **ChatInput**: Auto-resizing textarea with keyboard navigation + +#### State Management (Jotai Atoms) + +**Message State**: +```typescript +messagesAtom: PrimitiveAtom> +messagesSplitAtom: SplitAtom> +latestMessageAtom: Atom +addMessageAtom: WritableAtom +updateLastMessageAtom: WritableAtom +removeLastMessageAtom: WritableAtom +``` + +**Configuration State**: +```typescript +presetKey: Atom // Current AI preset selection +presetMap: Atom<{[k: string]: MetaType}> // Available AI presets +mergedPresets: Atom // Merged configuration hierarchy +aiOpts: Atom // Final AI options for requests +``` + +**UI State**: +```typescript +locked: PrimitiveAtom // Prevents input during AI response +viewIcon: Atom // Header icon +viewName: Atom // Header title +viewText: Atom // Dynamic header elements +endIconButtons: Atom // Header action buttons +``` + +#### Configuration Hierarchy + +The AI configuration follows a three-tier hierarchy (lowest to highest priority): +1. **Global Settings**: `atoms.settingsAtom["ai:*"]` +2. **Preset Configuration**: `presets[presetKey]["ai:*"]` +3. **Block Metadata**: `block.meta["ai:*"]` + +Configuration is merged using `mergeMeta()` utility, allowing fine-grained overrides at each level. + +#### Data Flow - Frontend + +``` +User Input → sendMessage() → +├── Add user message to UI +├── Create WaveAIStreamRequest +├── Call RpcApi.StreamWaveAiCommand() +├── Add typing indicator +└── Stream response handling: + ├── Update message incrementally + ├── Handle errors + └── Save complete conversation +``` + +### Backend Architecture (`pkg/waveai/`) + +#### Core Interface + +**AIBackend Interface**: +```go +type AIBackend interface { + StreamCompletion( + ctx context.Context, + request wshrpc.WaveAIStreamRequest, + ) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] +} +``` + +#### Backend Implementations + +**1. OpenAIBackend** (`openaibackend.go`) +- **Providers**: OpenAI, Azure OpenAI, Cloudflare Azure +- **Features**: + - Reasoning model support (o1, o3, o4, gpt-5) + - Proxy support + - Multiple API types (OpenAI, Azure, AzureAD, CloudflareAzure) +- **Streaming**: Uses `go-openai` library for SSE streaming + +**2. AnthropicBackend** (`anthropicbackend.go`) +- **Provider**: Anthropic Claude +- **Features**: + - Custom SSE parser for Anthropic's event format + - System message handling + - Usage token tracking +- **Events**: `message_start`, `content_block_delta`, `message_stop`, etc. + +**3. WaveAICloudBackend** (`cloudbackend.go`) +- **Provider**: Wave's cloud proxy service +- **Transport**: WebSocket connection to Wave cloud +- **Features**: + - Fallback when no API token/baseURL provided + - Built-in rate limiting and abuse protection + +**4. PerplexityBackend** (`perplexitybackend.go`) +- **Provider**: Perplexity AI +- **Implementation**: Similar to OpenAI backend + +**5. GoogleBackend** (`googlebackend.go`) +- **Provider**: Google AI (Gemini) +- **Implementation**: Custom integration for Google's API + +#### Backend Routing Logic + +```go +func RunAICommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { + // Route based on request.Opts.APIType: + switch request.Opts.APIType { + case "anthropic": + backend = AnthropicBackend{} + case "perplexity": + backend = PerplexityBackend{} + case "google": + backend = GoogleBackend{} + default: + if IsCloudAIRequest(request.Opts) { + backend = WaveAICloudBackend{} + } else { + backend = OpenAIBackend{} + } + } + return backend.StreamCompletion(ctx, request) +} +``` + +### RPC Communication Layer + +#### WSH RPC Integration + +**Command**: `streamwaveai` +**Type**: Response Stream (one request, multiple responses) + +**Request Type** (`WaveAIStreamRequest`): +```go +type WaveAIStreamRequest struct { + ClientId string `json:"clientid,omitempty"` + Opts *WaveAIOptsType `json:"opts"` + Prompt []WaveAIPromptMessageType `json:"prompt"` +} +``` + +**Response Type** (`WaveAIPacketType`): +```go +type WaveAIPacketType struct { + Type string `json:"type"` + Model string `json:"model,omitempty"` + Created int64 `json:"created,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + Usage *WaveAIUsageType `json:"usage,omitempty"` + Index int `json:"index,omitempty"` + Text string `json:"text,omitempty"` + Error string `json:"error,omitempty"` +} +``` + +#### Configuration Types + +**AI Options** (`WaveAIOptsType`): +```go +type WaveAIOptsType struct { + Model string `json:"model"` + APIType string `json:"apitype,omitempty"` + APIToken string `json:"apitoken"` + OrgID string `json:"orgid,omitempty"` + APIVersion string `json:"apiversion,omitempty"` + BaseURL string `json:"baseurl,omitempty"` + ProxyURL string `json:"proxyurl,omitempty"` + MaxTokens int `json:"maxtokens,omitempty"` + MaxChoices int `json:"maxchoices,omitempty"` + TimeoutMs int `json:"timeoutms,omitempty"` +} +``` + +### Data Persistence + +#### Chat History Storage + +**Frontend**: +- **Method**: `fetchWaveFile(blockId, "aidata")` +- **Format**: JSON array of `WaveAIPromptMessageType` +- **Sliding Window**: Last 30 messages (`slidingWindowSize = 30`) + +**Backend**: +- **Service**: `BlockService.SaveWaveAiData(blockId, history)` +- **Storage**: Block-associated file storage +- **Persistence**: Automatic save after each complete exchange + +#### Message Format + +**UI Messages** (`ChatMessageType`): +```typescript +interface ChatMessageType { + id: string; + user: string; // "user" | "assistant" | "error" + text: string; + isUpdating?: boolean; +} +``` + +**Stored Messages** (`WaveAIPromptMessageType`): +```go +type WaveAIPromptMessageType struct { + Role string `json:"role"` // "user" | "assistant" | "system" | "error" + Content string `json:"content"` + Name string `json:"name,omitempty"` +} +``` + +### Error Handling + +#### Frontend Error Handling + +1. **Network Errors**: Caught in streaming loop, displayed as error messages +2. **Empty Responses**: Automatically remove typing indicator +3. **Cancellation**: User can cancel via stop button (`model.cancel = true`) +4. **Partial Responses**: Saved even if incomplete due to errors + +#### Backend Error Handling + +1. **Panic Recovery**: All backends use `panichandler.PanicHandler()` +2. **Context Cancellation**: Proper cleanup on request cancellation +3. **Provider Errors**: Wrapped and forwarded to frontend +4. **Connection Errors**: Detailed error messages for debugging + +### UI Features + +#### Message Rendering + +- **Markdown Support**: Full markdown rendering with syntax highlighting +- **Role-based Styling**: Different colors/layouts for user/assistant/error messages +- **Typing Indicator**: Animated dots during AI response +- **Font Configuration**: Configurable font sizes via presets + +#### Input Handling + +- **Auto-resize**: Textarea grows/shrinks with content (max 5 lines) +- **Keyboard Navigation**: + - Enter to send + - Cmd+L to clear history + - Arrow keys for code block selection +- **Code Block Selection**: Navigate through code blocks in responses + +#### Scroll Management + +- **Auto-scroll**: Automatically scrolls to new messages +- **User Scroll Detection**: Pauses auto-scroll when user manually scrolls +- **Smart Resume**: Resumes auto-scroll when near bottom + +### Configuration Management + +#### Preset System + +**Preset Structure**: +```json +{ + "ai@preset-name": { + "display:name": "Preset Display Name", + "display:order": 1, + "ai:model": "gpt-4", + "ai:apitype": "openai", + "ai:apitoken": "sk-...", + "ai:baseurl": "https://api.openai.com/v1", + "ai:maxtokens": 4000, + "ai:fontsize": "14px", + "ai:fixedfontsize": "12px" + } +} +``` + +**Configuration Keys**: +- `ai:model` - AI model name +- `ai:apitype` - Provider type (openai, anthropic, perplexity, google) +- `ai:apitoken` - API authentication token +- `ai:baseurl` - Custom API endpoint +- `ai:proxyurl` - HTTP proxy URL +- `ai:maxtokens` - Maximum response tokens +- `ai:timeoutms` - Request timeout +- `ai:fontsize` - UI font size +- `ai:fixedfontsize` - Code block font size + +#### Provider Detection + +The UI automatically detects and displays the active provider: + +- **Cloud**: Wave's proxy (no token/baseURL) +- **Local**: localhost/127.0.0.1 endpoints +- **Remote**: External API endpoints +- **Provider-specific**: Anthropic, Perplexity with custom icons + +### Performance Considerations + +#### Frontend Optimizations + +- **Jotai Atoms**: Granular reactivity, only re-render affected components +- **Memo Components**: `ChatWindow` and `ChatItem` are memoized +- **Throttled Scrolling**: Scroll events throttled to 100ms +- **Debounced Scroll Detection**: User scroll detection debounced to 300ms + +#### Backend Optimizations + +- **Streaming**: All responses are streamed for immediate feedback +- **Context Cancellation**: Proper cleanup prevents resource leaks +- **Connection Pooling**: HTTP clients reuse connections +- **Error Recovery**: Graceful degradation on provider failures + +### Security Considerations + +#### API Token Handling + +- **Storage**: Tokens stored in encrypted configuration +- **Transmission**: Tokens only sent to configured endpoints +- **Validation**: Backend validates token format and permissions + +#### Request Validation + +- **Input Sanitization**: User input validated before sending +- **Rate Limiting**: Cloud backend includes built-in rate limiting +- **Error Filtering**: Sensitive error details filtered from UI + +### Extension Points + +#### Adding New Providers + +1. **Implement AIBackend Interface**: Create new backend struct +2. **Add Provider Detection**: Update `RunAICommand()` routing logic +3. **Add Configuration**: Define provider-specific config keys +4. **Update UI**: Add provider detection in `viewText` atom + +#### Custom Message Types + +1. **Extend ChatMessageType**: Add new user types +2. **Update ChatItem Rendering**: Handle new message types +3. **Modify Storage**: Update persistence format if needed + +This architecture provides a flexible, extensible foundation for AI chat functionality while maintaining clean separation between UI, business logic, and provider integrations. \ No newline at end of file From dcfaefa6cdcb6cadf6a9e92c44fceb1811b7ce9e Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 19 Aug 2025 22:16:26 -0700 Subject: [PATCH 02/29] small fixes --- .roo/rules/rules.md | 2 ++ staticcheck.conf | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 519bacf82c..c59aae1913 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -4,6 +4,8 @@ Wave Terminal is a modern terminal which provides graphical blocks, dynamic layo It has a TypeScript/React frontend and a Go backend. They talk together over `wshrpc` a custom RPC protocol that is implemented over websocket (and domain sockets). +The frontend uses yarn (berry). + ### Coding Guidelines - **Go Conventions**: diff --git a/staticcheck.conf b/staticcheck.conf index 578e4351d4..6ab1cac1af 100644 --- a/staticcheck.conf +++ b/staticcheck.conf @@ -1,2 +1,2 @@ -checks = ["all", "-ST1005", "-QF1003", "-ST1000", "-ST1003"] +checks = ["all", "-ST1005", "-QF1003", "-ST1000", "-ST1003", "-ST1020"] From 8ad735718e8ca55995b1b16b403953b9cacf8361 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 19 Aug 2025 22:42:54 -0700 Subject: [PATCH 03/29] fix linter errors --- pkg/wcore/workspace.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 5ae340d0fa..d61b2ad7b7 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -128,9 +128,6 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool, return false, "", fmt.Errorf("error retrieving workspaceList: %w", err) } - if err != nil { - return false, "", fmt.Errorf("error getting workspace: %w", err) - } if workspace.Name != "" && workspace.Icon != "" && !force && (len(workspace.TabIds) > 0 || len(workspace.PinnedTabIds) > 0) { log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId) return false, "", nil @@ -231,7 +228,7 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate presetMeta, presetErr := getTabPresetMeta() if presetErr != nil { log.Printf("error getting tab preset meta: %v\n", presetErr) - } else if presetMeta != nil && len(presetMeta) > 0 { + } else if len(presetMeta) > 0 { tabORef := waveobj.ORefFromWaveObj(tab) wstore.UpdateObjectMeta(ctx, *tabORef, presetMeta, true) } From 4b87e1b2f1b3918780632f9f846bdb65aa3283f0 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 19 Aug 2025 22:43:55 -0700 Subject: [PATCH 04/29] checkpoint --- aiprompts/usechat-backend-design.md | 463 ++++++++++++++++ frontend/app/view/waveai/waveai.tsx | 24 +- frontend/app/view/waveai/waveaiusechat.tsx | 606 +++++++++++++++++++++ package.json | 8 +- pkg/waveai/usechat.go | 390 +++++++++++++ pkg/web/web.go | 30 +- yarn.lock | 142 +++++ 7 files changed, 1657 insertions(+), 6 deletions(-) create mode 100644 aiprompts/usechat-backend-design.md create mode 100644 frontend/app/view/waveai/waveaiusechat.tsx create mode 100644 pkg/waveai/usechat.go diff --git a/aiprompts/usechat-backend-design.md b/aiprompts/usechat-backend-design.md new file mode 100644 index 0000000000..f5793718c1 --- /dev/null +++ b/aiprompts/usechat-backend-design.md @@ -0,0 +1,463 @@ +# useChat Compatible Backend Design for Wave Terminal + +## Overview + +This document outlines how to create a `useChat()` compatible backend API using Go and Server-Sent Events (SSE) to replace the current complex RPC-based AI chat system. The goal is to leverage Vercel AI SDK's `useChat()` hook while maintaining all existing AI provider functionality. + +## Current vs Target Architecture + +### Current Architecture +``` +Frontend (React) → Custom RPC → Go Backend → AI Providers +- 10+ Jotai atoms for state management +- Custom WaveAIStreamRequest/WaveAIPacketType +- Complex configuration merging in frontend +- Custom streaming protocol over WebSocket +``` + +### Target Architecture +``` +Frontend (useChat) → HTTP/SSE → Go Backend → AI Providers +- Single useChat() hook manages all state +- Standard HTTP POST + SSE streaming +- Backend-driven configuration resolution +- Standard AI SDK streaming format +``` + +## API Design + +### 1. Endpoint Structure + +**Chat Streaming Endpoint:** +``` +POST /api/ai/chat/{blockId}?preset={presetKey} +``` + +**Conversation Persistence Endpoints:** +``` +POST /api/ai/conversations/{blockId} # Save conversation +GET /api/ai/conversations/{blockId} # Load conversation +``` + +**Why this approach:** +- `blockId`: Identifies the conversation context (existing Wave concept) +- `preset`: URL parameter for AI configuration preset +- **Separate persistence**: Clean separation of streaming vs storage +- **Fast localhost calls**: Frontend can call both endpoints quickly +- **Simple backend**: Each endpoint has single responsibility + +### 2. Request Format & Message Flow + +**Simplified Approach:** +- Frontend manages **entire conversation state** (like all modern chat apps) +- Frontend sends **complete message history** with each request +- Backend just processes the messages and streams response +- Frontend handles persistence via existing Wave file system + +**Standard useChat() Request:** +```json +{ + "messages": [ + { + "id": "msg-1", + "role": "user", + "content": "Hello world" + }, + { + "id": "msg-2", + "role": "assistant", + "content": "Hi there!" + }, + { + "id": "msg-3", + "role": "user", + "content": "How are you?" // <- NEW message user just typed + } + ] +} +``` + +**Backend Processing:** +1. **Receive complete conversation** from frontend +2. **Resolve AI configuration** (preset, model, etc.) +3. **Send messages directly** to AI provider +4. **Stream response** back to frontend +5. **Frontend calls separate persistence endpoint** when needed + +**Optional Extensions:** +```json +{ + "messages": [...], + "options": { + "temperature": 0.7, + "maxTokens": 1000, + "model": "gpt-4" // Override preset model + } +} +``` + +### 3. Configuration Resolution + +**Priority Order (backend resolves):** +1. **Request options** (highest priority) +2. **URL preset parameter** +3. **Block metadata** (`block.meta["ai:preset"]`) +4. **Global settings** (`settings["ai:preset"]`) +5. **Default preset** (lowest priority) + +**Backend Logic:** +```go +func resolveAIConfig(blockId, presetKey string, requestOptions map[string]any) (*WaveAIOptsType, error) { + // 1. Load block metadata + block := getBlock(blockId) + blockPreset := block.Meta["ai:preset"] + + // 2. Load global settings + settings := getGlobalSettings() + globalPreset := settings["ai:preset"] + + // 3. Resolve preset hierarchy + finalPreset := presetKey + if finalPreset == "" { + finalPreset = blockPreset + } + if finalPreset == "" { + finalPreset = globalPreset + } + if finalPreset == "" { + finalPreset = "default" + } + + // 4. Load and merge preset config + presetConfig := loadPreset(finalPreset) + + // 5. Apply request overrides + return mergeAIConfig(presetConfig, requestOptions), nil +} +``` + +### 4. Response Format (SSE) + +**Key Insight: Minimal Conversion** +Most AI providers (OpenAI, Anthropic) already return SSE streams. Instead of converting to our custom format and back, we can **proxy/transform** their streams directly to useChat format. + +**Headers:** +``` +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive +Access-Control-Allow-Origin: * +``` + +**useChat Expected Format:** +``` +data: {"type":"text","text":"Hello"} + +data: {"type":"text","text":" world"} + +data: {"type":"text","text":"!"} + +data: {"type":"finish","finish_reason":"stop","usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}} + +data: [DONE] +``` + +**Provider Stream Transformation:** +- **OpenAI**: Already SSE → direct proxy (no conversion needed) +- **Anthropic**: Already SSE → direct proxy (minimal field mapping) +- **Google**: Already streaming → direct proxy +- **Perplexity**: OpenAI-compatible → direct proxy +- **Wave Cloud**: WebSocket → **requires conversion** (only one needing transformation) + +**Error Format:** +``` +data: {"type":"error","error":"API key invalid"} + +data: [DONE] +``` + +## Implementation Plan + +### Phase 1: HTTP Handler + +```go +// Simplified approach: Direct provider streaming with minimal transformation +func (s *WshServer) HandleAIChat(w http.ResponseWriter, r *http.Request) { + // 1. Parse URL parameters + blockId := mux.Vars(r)["blockId"] + presetKey := r.URL.Query().Get("preset") + + // 2. Parse request body + var req struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + Options map[string]any `json:"options,omitempty"` + } + json.NewDecoder(r.Body).Decode(&req) + + // 3. Resolve configuration + aiOpts, err := resolveAIConfig(blockId, presetKey, req.Options) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + + // 4. Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // 5. Route to provider and stream directly + switch aiOpts.APIType { + case "openai", "perplexity": + // Direct proxy - these are already SSE compatible + streamDirectSSE(w, r.Context(), aiOpts, req.Messages) + case "anthropic": + // Direct proxy with minimal field mapping + streamAnthropicSSE(w, r.Context(), aiOpts, req.Messages) + case "google": + // Direct proxy + streamGoogleSSE(w, r.Context(), aiOpts, req.Messages) + default: + // Wave Cloud - only one requiring conversion (WebSocket → SSE) + if isCloudAIRequest(aiOpts) { + streamWaveCloudToUseChat(w, r.Context(), aiOpts, req.Messages) + } else { + http.Error(w, "Unsupported provider", 400) + } + } +} + +// Example: Direct OpenAI streaming (minimal conversion) +func streamOpenAIToUseChat(w http.ResponseWriter, ctx context.Context, opts *WaveAIOptsType, messages []Message) { + client := openai.NewClient(opts.APIToken) + + stream, err := client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ + Model: opts.Model, + Messages: convertToOpenAIMessages(messages), + Stream: true, + }) + if err != nil { + fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", err.Error()) + fmt.Fprintf(w, "data: [DONE]\n\n") + return + } + defer stream.Close() + + for { + response, err := stream.Recv() + if errors.Is(err, io.EOF) { + fmt.Fprintf(w, "data: [DONE]\n\n") + return + } + if err != nil { + fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", err.Error()) + fmt.Fprintf(w, "data: [DONE]\n\n") + return + } + + // Direct transformation: OpenAI format → useChat format + for _, choice := range response.Choices { + if choice.Delta.Content != "" { + fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", choice.Delta.Content) + } + if choice.FinishReason != "" { + fmt.Fprintf(w, "data: {\"type\":\"finish\",\"finish_reason\":%q}\n\n", choice.FinishReason) + } + } + + w.(http.Flusher).Flush() + } +} + +// Wave Cloud conversion (only provider needing transformation) +func streamWaveCloudToUseChat(w http.ResponseWriter, ctx context.Context, opts *WaveAIOptsType, messages []Message) { + // Use existing Wave Cloud WebSocket logic + waveReq := wshrpc.WaveAIStreamRequest{ + Opts: opts, + Prompt: convertMessagesToPrompt(messages), + } + + stream := waveai.RunAICommand(ctx, waveReq) // Returns WebSocket stream + + // Convert Wave Cloud packets to useChat SSE format + for packet := range stream { + if packet.Error != nil { + fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", packet.Error.Error()) + break + } + + resp := packet.Response + if resp.Text != "" { + fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", resp.Text) + } + if resp.FinishReason != "" { + usage := "" + if resp.Usage != nil { + usage = fmt.Sprintf(",\"usage\":{\"prompt_tokens\":%d,\"completion_tokens\":%d,\"total_tokens\":%d}", + resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) + } + fmt.Fprintf(w, "data: {\"type\":\"finish\",\"finish_reason\":%q%s}\n\n", resp.FinishReason, usage) + } + + w.(http.Flusher).Flush() + } + + fmt.Fprintf(w, "data: [DONE]\n\n") +} +``` + +### Phase 2: Frontend Integration + +```typescript +import { useChat } from '@ai-sdk/react'; + +function WaveAI({ blockId }: { blockId: string }) { + // Get current preset from block metadata or settings + const preset = useAtomValue(currentPresetAtom); + + const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({ + api: `/api/ai/chat/${blockId}?preset=${preset}`, + initialMessages: [], // Load from existing aidata file + onFinish: (message) => { + // Save conversation to aidata file + saveConversation(blockId, messages); + } + }); + + return ( +
+
+ {messages.map(message => ( +
+ +
+ ))} + {isLoading && } + {error &&
{error.message}
} +
+ +
+ +
+
+ ); +} +``` + +### Phase 3: Advanced Features + +#### Multi-modal Support +```typescript +// useChat supports multi-modal out of the box +const { messages, append } = useChat({ + api: `/api/ai/chat/${blockId}`, +}); + +// Send image + text +await append({ + role: 'user', + content: [ + { type: 'text', text: 'What do you see in this image?' }, + { type: 'image', image: imageFile } + ] +}); +``` + +#### Thinking Models +```go +// Backend detects thinking models and formats appropriately +if isThinkingModel(aiOpts.Model) { + // Send thinking content separately + fmt.Fprintf(w, "data: {\"type\":\"thinking\",\"text\":%q}\n\n", thinkingText) + fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", responseText) +} +``` + +#### Context Injection +```typescript +// Add system messages or context via useChat options +const { messages, append } = useChat({ + api: `/api/ai/chat/${blockId}`, + initialMessages: [ + { + role: 'system', + content: 'You are a helpful terminal assistant...' + } + ] +}); +``` + +## Migration Strategy + +### 1. Parallel Implementation +- Keep existing RPC system running +- Add new HTTP/SSE endpoint alongside +- Feature flag to switch between systems + +### 2. Gradual Migration +- Start with new blocks using useChat +- Migrate existing conversations on first interaction +- Remove RPC system once stable + +### 3. Backward Compatibility +- Existing aidata files work unchanged +- Same provider backends (OpenAI, Anthropic, etc.) +- Same configuration system + +## Benefits + +### Complexity Reduction +- **Frontend**: ~900 lines → ~100 lines (90% reduction) +- **State Management**: 10+ atoms → 1 useChat hook +- **Configuration**: Frontend merging → Backend resolution +- **Streaming**: Custom protocol → Standard SSE + +### Modern Features +- **Multi-modal**: Images, files, audio support +- **Thinking Models**: Built-in reasoning trace support +- **Conversation Management**: Edit, retry, branch conversations +- **Error Handling**: Automatic retry and error boundaries +- **Performance**: Optimized streaming and batching + +### Developer Experience +- **Type Safety**: Full TypeScript support +- **Testing**: Standard HTTP endpoints easier to test +- **Debugging**: Standard browser dev tools work +- **Documentation**: Leverage AI SDK docs and community + +## Configuration Examples + +### URL-based Configuration +``` +POST /api/ai/chat/block-123?preset=claude-coding +POST /api/ai/chat/block-456?preset=gpt4-creative +``` + +### Header-based Overrides +``` +POST /api/ai/chat/block-123 +X-AI-Model: gpt-4-turbo +X-AI-Temperature: 0.8 +``` + +### Request Body Options +```json +{ + "messages": [...], + "options": { + "model": "claude-3-sonnet", + "temperature": 0.7, + "maxTokens": 2000 + } +} +``` + +This design maintains all existing functionality while dramatically simplifying the implementation and adding modern AI chat capabilities. \ No newline at end of file diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index 3c36087cc8..355e1b5aba 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -19,6 +19,7 @@ import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overl import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { debounce, throttle } from "throttle-debounce"; import "./waveai.scss"; +import { WaveAiUseChat, WaveAiUseChatModel } from "./waveaiusechat"; interface ChatMessageType { id: string; @@ -296,7 +297,15 @@ export class WaveAiModel implements ViewModel { } get viewComponent(): ViewComponent { - return WaveAi; + // Check if we should use the new useChat implementation + const useNewImplementation = this.shouldUseNewImplementation(); + return useNewImplementation ? WaveAiUseChat : WaveAi; + } + + private shouldUseNewImplementation(): boolean { + // For now, check for a meta flag to enable the new implementation + const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {}; + return blockMeta["ai:usechat"] === "true" || blockMeta["ai:usechat"] === true; } dispose() { @@ -685,7 +694,7 @@ const ChatInput = forwardRef( } ); -const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { +const WaveAiOld = ({ model }: { model: WaveAiModel; blockId: string }) => { const { sendMessage } = model.useWaveAi(); const waveaiRef = useRef(null); const chatWindowRef = useRef(null); @@ -879,4 +888,15 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { ); }; +const WaveAi = ({ model, blockId }: { model: WaveAiModel; blockId: string }) => { + const useNewImplementation = true; + + if (useNewImplementation) { + const useChatModel = useMemo(() => new WaveAiUseChatModel(blockId), [blockId]); + return ; + } + + return ; +}; + export { WaveAi }; diff --git a/frontend/app/view/waveai/waveaiusechat.tsx b/frontend/app/view/waveai/waveaiusechat.tsx new file mode 100644 index 0000000000..4fe9f9ea7e --- /dev/null +++ b/frontend/app/view/waveai/waveaiusechat.tsx @@ -0,0 +1,606 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/app/element/button"; +import { Markdown } from "@/app/element/markdown"; +import { TypingIndicator } from "@/app/element/typingindicator"; +import { atoms, fetchWaveFile, WOS } from "@/store/global"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { BlockService, ObjectService } from "@/store/services"; +import { checkKeyPressed } from "@/util/keyutil"; +import { fireAndForget, isBlank, mergeMeta } from "@/util/util"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { atom, Atom, useAtomValue } from "jotai"; +import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; +import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { debounce, throttle } from "throttle-debounce"; + +interface WaveAiUseChatProps { + blockId: string; + model: WaveAiUseChatModelImpl; +} + +interface ChatMessage { + id: string; + role: "user" | "assistant" | "system"; + content: string; +} + +const slidingWindowSize = 30; + +class WaveAiUseChatModelImpl implements ViewModel { + viewType: string; + blockId: string; + blockAtom: Atom; + presetKey: Atom; + presetMap: Atom<{ [k: string]: MetaType }>; + mergedPresets: Atom; + aiOpts: Atom; + viewIcon?: Atom; + viewName?: Atom; + viewText?: Atom; + endIconButtons?: Atom; + textAreaRef: React.RefObject; + + constructor(blockId: string) { + this.viewType = "waveai"; + this.blockId = blockId; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.viewIcon = atom("sparkles"); + this.viewName = atom("Wave AI"); + this.textAreaRef = React.createRef(); + + this.presetKey = atom((get) => { + const metaPresetKey = get(this.blockAtom).meta["ai:preset"]; + const globalPresetKey = get(atoms.settingsAtom)["ai:preset"]; + return metaPresetKey ?? globalPresetKey; + }); + + this.presetMap = atom((get) => { + const fullConfig = get(atoms.fullConfigAtom); + const presets = fullConfig.presets; + const settings = fullConfig.settings; + return Object.fromEntries( + Object.entries(presets) + .filter(([k]) => k.startsWith("ai@")) + .map(([k, v]) => { + const aiPresetKeys = Object.keys(v).filter((k) => k.startsWith("ai:")); + const newV = { ...v }; + newV["display:name"] = + aiPresetKeys.length == 1 && aiPresetKeys.includes("ai:*") + ? `${newV["display:name"] ?? "Default"} (${settings["ai:model"]})` + : newV["display:name"]; + return [k, newV]; + }) + ); + }); + + this.mergedPresets = atom((get) => { + const meta = get(this.blockAtom).meta; + let settings = get(atoms.settingsAtom); + let presetKey = get(this.presetKey); + let presets = get(atoms.fullConfigAtom).presets; + let selectedPresets = presets?.[presetKey] ?? {}; + + let mergedPresets: MetaType = {}; + mergedPresets = mergeMeta(settings, selectedPresets, "ai"); + mergedPresets = mergeMeta(mergedPresets, meta, "ai"); + + return mergedPresets; + }); + + this.aiOpts = atom((get) => { + const mergedPresets = get(this.mergedPresets); + + const opts: WaveAIOptsType = { + model: mergedPresets["ai:model"] ?? null, + apitype: mergedPresets["ai:apitype"] ?? null, + orgid: mergedPresets["ai:orgid"] ?? null, + apitoken: mergedPresets["ai:apitoken"] ?? null, + apiversion: mergedPresets["ai:apiversion"] ?? null, + maxtokens: mergedPresets["ai:maxtokens"] ?? null, + timeoutms: mergedPresets["ai:timeoutms"] ?? 60000, + baseurl: mergedPresets["ai:baseurl"] ?? null, + proxyurl: mergedPresets["ai:proxyurl"] ?? null, + }; + return opts; + }); + + this.viewText = atom((get) => { + const viewTextChildren: HeaderElem[] = []; + const aiOpts = get(this.aiOpts); + const presets = get(this.presetMap); + const presetKey = get(this.presetKey); + const presetName = presets[presetKey]?.["display:name"] ?? ""; + const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); + + // Handle known API providers + switch (aiOpts?.apitype) { + case "anthropic": + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "globe", + title: `Using Remote Anthropic API (${aiOpts.model})`, + noAction: true, + }); + break; + case "perplexity": + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "globe", + title: `Using Remote Perplexity API (${aiOpts.model})`, + noAction: true, + }); + break; + default: + if (isCloud) { + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "cloud", + title: "Using Wave's AI Proxy (gpt-4o-mini)", + noAction: true, + }); + } else { + const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint"; + const modelName = aiOpts.model; + if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) { + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "location-dot", + title: `Using Local Model @ ${baseUrl} (${modelName})`, + noAction: true, + }); + } else { + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "globe", + title: `Using Remote Model @ ${baseUrl} (${modelName})`, + noAction: true, + }); + } + } + } + + const dropdownItems = Object.entries(presets) + .sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1)) + .map( + (preset) => + ({ + label: preset[1]["display:name"], + onClick: () => + fireAndForget(() => + ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { + "ai:preset": preset[0], + }) + ), + }) as MenuItem + ); + + viewTextChildren.push({ + elemtype: "menubutton", + text: presetName, + title: "Select AI Configuration", + items: dropdownItems, + }); + return viewTextChildren; + }); + + this.endIconButtons = atom((_) => { + let clearButton: IconButtonDecl = { + elemtype: "iconbutton", + icon: "delete-left", + title: "Clear Chat History", + click: this.clearMessages.bind(this), + }; + return [clearButton]; + }); + } + + get viewComponent(): ViewComponent { + return WaveAiUseChat; + } + + dispose() { + // No cleanup needed for useChat version + } + + async populateMessages(): Promise { + const history = await this.fetchAiData(); + return history.map((msg) => ({ + id: crypto.randomUUID(), + role: msg.role as "user" | "assistant" | "system", + content: msg.content, + })); + } + + async fetchAiData(): Promise> { + const { data } = await fetchWaveFile(this.blockId, "aidata"); + if (!data) { + return []; + } + const history: Array = JSON.parse(new TextDecoder().decode(data)); + return history.slice(Math.max(history.length - slidingWindowSize, 0)); + } + + async saveMessages(messages: ChatMessage[]): Promise { + const history: WaveAIPromptMessageType[] = messages.map((msg) => ({ + role: msg.role, + content: msg.content, + })); + await BlockService.SaveWaveAiData(this.blockId, history); + } + + giveFocus(): boolean { + if (this?.textAreaRef?.current) { + this.textAreaRef.current?.focus(); + return true; + } + return false; + } + + async clearMessages() { + await BlockService.SaveWaveAiData(this.blockId, []); + } + + keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { + if (checkKeyPressed(waveEvent, "Cmd:l")) { + fireAndForget(this.clearMessages.bind(this)); + return true; + } + return false; + } +} + +const ChatWindow = memo( + forwardRef< + OverlayScrollbarsComponentRef, + { messages: ChatMessage[]; isLoading: boolean; error: Error | null; fontSize?: string; fixedFontSize?: string } + >(({ messages, isLoading, error, fontSize, fixedFontSize }, ref) => { + const osRef = useRef(null); + const [userHasScrolled, setUserHasScrolled] = useState(false); + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); + + useImperativeHandle(ref, () => osRef.current!, []); + + const scrollToBottom = useCallback(() => { + if (osRef.current && shouldAutoScroll) { + const viewport = osRef.current.osInstance()?.elements().viewport; + if (viewport) { + viewport.scrollTop = viewport.scrollHeight; + } + } + }, [shouldAutoScroll]); + + const handleScroll = useMemo( + () => + throttle(100, () => { + if (osRef.current) { + const viewport = osRef.current.osInstance()?.elements().viewport; + if (viewport) { + const { scrollTop, scrollHeight, clientHeight } = viewport; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; + setShouldAutoScroll(isNearBottom); + if (!isNearBottom && !userHasScrolled) { + setUserHasScrolled(true); + } + } + } + }), + [userHasScrolled] + ); + + const resetUserScroll = useMemo( + () => + debounce(300, () => { + setUserHasScrolled(false); + }), + [] + ); + + useEffect(() => { + scrollToBottom(); + }, [messages, isLoading, scrollToBottom]); + + useEffect(() => { + if (shouldAutoScroll && userHasScrolled) { + resetUserScroll(); + } + }, [shouldAutoScroll, userHasScrolled, resetUserScroll]); + + return ( +
+ +
+ {messages.map((message) => ( + + ))} + {isLoading && ( +
+
+ +
+ +
+ )} + {error && ( +
+
+ +
+
+
+ Error: {error.message} +
+
+
+ )} +
+
+
+ ); + }) +); +ChatWindow.displayName = "ChatWindow"; + +const ChatItem = memo( + ({ message, fontSize, fixedFontSize }: { message: ChatMessage; fontSize?: string; fixedFontSize?: string }) => { + const { role, content } = message; + + if (role === "user") { + return ( +
+
+ +
+
+ +
+
+ ); + } + + if (role === "assistant") { + return ( +
+
+ +
+
+ +
+
+ ); + } + + return null; + } +); +ChatItem.displayName = "ChatItem"; + +const ChatInput = memo( + ({ + input, + handleInputChange, + handleSubmit, + isLoading, + textAreaRef, + }: { + input: string; + handleInputChange: (e: React.ChangeEvent) => void; + handleSubmit: (e: React.FormEvent) => void; + isLoading: boolean; + textAreaRef: React.RefObject; + }) => { + const [textAreaHeight, setTextAreaHeight] = useState(25); + const maxLines = 5; + const lineHeight = 17; + const minHeight = 25; + const maxHeight = minHeight + (maxLines - 1) * lineHeight; + + const adjustTextAreaHeight = useCallback(() => { + if (textAreaRef.current) { + const textArea = textAreaRef.current; + textArea.style.height = `${minHeight}px`; + const scrollHeight = textArea.scrollHeight; + const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight); + setTextAreaHeight(newHeight); + textArea.style.height = `${newHeight}px`; + } + }, [textAreaRef, minHeight, maxHeight]); + + useEffect(() => { + adjustTextAreaHeight(); + }, [input, adjustTextAreaHeight]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSubmit(event as any); + return; + } + }, + [handleSubmit] + ); + + return ( +
+
+
+