Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions studio/ai_chat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

const (
chatModel = "claude-sonnet-4-6"
chatAPIURL = "https://api.anthropic.com/v1/messages"
chatAPIVersion = "2023-06-01"
chatMaxTokens = 4096

// Sonnet 4.6 pricing per 1M tokens (USD)
priceInputPer1M = 3.0
priceOutputPer1M = 15.0
)

// ChatMessage is one turn in the conversation.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}

// ChatResponse is returned from the Chat method.
type ChatResponse struct {
Content string `json:"content"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CostUSD float64 `json:"costUSD"`
}

var chatHTTPClient = &http.Client{Timeout: 120 * time.Second}

// GetAPIKey retrieves the stored Anthropic API key from the platform keychain.
// Returns an empty string (not an error) when no key is stored.
func (a *App) GetAPIKey() (string, error) {
key, err := keychainGet()
if err != nil {
return "", nil
}
return key, nil
}

// SetAPIKey stores the Anthropic API key in the platform keychain.
func (a *App) SetAPIKey(key string) error {
if key == "" {
return keychainDelete()
}
return keychainSet(key)
}

// DeleteAPIKey removes the stored API key from the platform keychain.
func (a *App) DeleteAPIKey() error {
return keychainDelete()
}

// Chat sends a multi-turn conversation to the Claude API and returns the
// assistant reply together with token usage for the cost counter.
//
// messages is the full conversation history (user + assistant alternating).
// fileContent is the current editor file, injected into the system prompt.
func (a *App) Chat(messages []ChatMessage, fileContent string) (ChatResponse, error) {
apiKey, _ := keychainGet()
if apiKey == "" {
return ChatResponse{}, fmt.Errorf("no API key stored — add your Anthropic key in the AI panel")
}

systemPrompt := `You are an expert FlutterProbe test assistant. You help users write, debug, and improve ProbeScript test files.

ProbeScript syntax reference:
- Tests: test "name" (indented steps below)
- Steps: tap "Text", tap #key, type "value" into "Field", see "Text", wait until "Text" appears, swipe up/down/left/right, scroll up/down, go back, open the app, take screenshot "name"
- Conditional: tap "X" if visible, wait until "X" appears
- Tags on next line: @smoke @critical

Keep answers concise. When suggesting changes, output valid ProbeScript.`

if fileContent != "" {
systemPrompt += "\n\nCurrent file:\n```\n" + fileContent + "\n```"
}

type reqMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
type reqBody struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system"`
Messages []reqMsg `json:"messages"`
}

msgs := make([]reqMsg, len(messages))
for i, m := range messages {
msgs[i] = reqMsg{Role: m.Role, Content: m.Content}
}

payload, err := json.Marshal(reqBody{
Model: chatModel,
MaxTokens: chatMaxTokens,
System: systemPrompt,
Messages: msgs,
})
if err != nil {
return ChatResponse{}, fmt.Errorf("marshal: %w", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatAPIURL, bytes.NewReader(payload))
if err != nil {
return ChatResponse{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", chatAPIVersion)

resp, err := chatHTTPClient.Do(req)
if err != nil {
return ChatResponse{}, fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return ChatResponse{}, fmt.Errorf("read response: %w", err)
}

if resp.StatusCode == 401 {
return ChatResponse{}, fmt.Errorf("invalid API key — check your key in the AI panel")
}
if resp.StatusCode == 429 {
return ChatResponse{}, fmt.Errorf("rate limited — try again shortly")
}
if resp.StatusCode != 200 {
return ChatResponse{}, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}

var apiResp struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return ChatResponse{}, fmt.Errorf("parse response: %w", err)
}
if apiResp.Error != nil {
return ChatResponse{}, fmt.Errorf("API error: %s", apiResp.Error.Message)
}

text := ""
for _, c := range apiResp.Content {
if c.Type == "text" {
text += c.Text
}
}

in := apiResp.Usage.InputTokens
out := apiResp.Usage.OutputTokens
cost := float64(in)*priceInputPer1M/1_000_000 + float64(out)*priceOutputPer1M/1_000_000

return ChatResponse{
Content: text,
InputTokens: in,
OutputTokens: out,
CostUSD: cost,
}, nil
}
46 changes: 46 additions & 0 deletions studio/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<button id="btn-connect" class="btn" disabled title="Connect to selected device">Connect</button>
<span class="sep"></span>
<button id="btn-settings" class="btn btn-icon-only" title="Workspace settings (probe.yaml)">⚙</button>
<button id="btn-ai-toggle" class="btn btn-icon-only" title="Toggle AI chat (⌘⇧A)">✦</button>
</div>
<div class="status">
<span id="status-dot" class="status-dot status-disconnected"></span>
Expand Down Expand Up @@ -97,6 +98,29 @@
</section>
</main>

<section id="chat-pane" hidden>
<div class="pane-title">
<span>AI Chat <span class="dim" style="font-size:10px;font-weight:normal">claude-sonnet-4-6 · BYOK</span></span>
<span id="chat-cost" class="dim chat-cost"></span>
<span class="pane-actions">
<button id="btn-chat-key" class="btn-mini" title="Set API key">🔑</button>
<button id="btn-chat-clear" class="btn-mini" title="Clear conversation">✕</button>
</span>
</div>
<div id="chat-messages" class="chat-messages"></div>
<div class="chat-input-row">
<textarea
id="chat-input"
class="chat-input"
placeholder="Ask about your test file… (Enter to send, Shift+Enter for newline)"
rows="2"
autocomplete="off"
spellcheck="true"
></textarea>
<button id="btn-chat-send" class="btn btn-primary chat-send-btn" title="Send (Enter)">▶</button>
</div>
</section>

<div id="toast-container" aria-live="polite"></div>

<div id="settings-overlay" class="overlay" hidden>
Expand Down Expand Up @@ -175,6 +199,7 @@ <h2>Keyboard shortcuts</h2>
<tr><td><kbd>⌘</kbd> <kbd>B</kbd></td><td>Connect / disconnect device</td></tr>
<tr><td><kbd>⌘</kbd> <kbd>P</kbd></td><td>Open workspace folder</td></tr>
<tr><td><kbd>⌘</kbd> <kbd>K</kbd></td><td>Refresh device list</td></tr>
<tr><td><kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>A</kbd></td><td>Toggle AI chat</td></tr>
<tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close this help</td></tr>
</tbody>
Expand All @@ -185,6 +210,27 @@ <h2>Keyboard shortcuts</h2>
</div>
</div>

<div id="api-key-overlay" class="overlay" hidden>
<div class="overlay-card">
<div class="overlay-header">
<h2>Anthropic API key</h2>
<button id="btn-api-key-close" class="btn-mini" title="Close">✕</button>
</div>
<p class="dim">
Your key is stored in the <strong>system keychain</strong> (macOS Keychain / Windows Credential Manager /
Linux libsecret). It is never written to disk or sent anywhere other than <code>api.anthropic.com</code>.
</p>
<label class="settings-row">
<span>API key</span>
<input id="api-key-input" type="password" placeholder="sk-ant-…" autocomplete="off" spellcheck="false" />
</label>
<div class="settings-actions">
<button id="btn-api-key-save" class="btn btn-primary">Save key</button>
<button id="btn-api-key-delete" class="btn">Remove key</button>
</div>
</div>
</div>

<script type="module" src="/src/main.ts"></script>
</body>
</html>
Loading
Loading