diff --git a/studio/ai_chat.go b/studio/ai_chat.go new file mode 100644 index 0000000..77bdcd0 --- /dev/null +++ b/studio/ai_chat.go @@ -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 +} diff --git a/studio/frontend/index.html b/studio/frontend/index.html index 4c6770e..bcf5339 100644 --- a/studio/frontend/index.html +++ b/studio/frontend/index.html @@ -30,6 +30,7 @@ +
@@ -97,6 +98,29 @@ + +
+ + diff --git a/studio/frontend/src/main.ts b/studio/frontend/src/main.ts index 705dbc0..7b2ed2c 100644 --- a/studio/frontend/src/main.ts +++ b/studio/frontend/src/main.ts @@ -6,9 +6,12 @@ import { initResults } from "./results"; import { toast } from "./toast"; import { + Chat, Connect, ConnectWiFi, + DeleteAPIKey, Disconnect, + GetAPIKey, Lint, ListDevices, ListDir, @@ -17,6 +20,7 @@ import { ReadFile, RunFile, SaveWorkspaceSettings, + SetAPIKey, StartRecording, StartWiFiDiscovery, Status, @@ -25,6 +29,8 @@ import { WriteFile, } from "../wailsjs/go/main/App"; import { main } from "../wailsjs/go/models"; +type ChatMessage = { role: string; content: string }; +type ChatResponse = { content: string; inputTokens: number; outputTokens: number; costUSD: number }; import { EventsOn } from "../wailsjs/runtime/runtime"; const WORKSPACE_KEY = "fp.studio.workspace"; @@ -301,6 +307,12 @@ window.addEventListener("keydown", (e) => { e.preventDefault(); refreshDevices(); break; + case "a": + if (e.shiftKey) { + e.preventDefault(); + showChatPane(!chatVisible); + } + break; } }); @@ -885,3 +897,141 @@ runLint(); setStatus("disconnected", "disconnected"); } })(); + +// ---- AI Chat ------------------------------------------------------------ + +let chatHistory: ChatMessage[] = []; +let chatTotalCost = 0; +let chatInputTokens = 0; +let chatOutputTokens = 0; +let chatVisible = false; + +function showChatPane(visible: boolean) { + chatVisible = visible; + $("chat-pane").hidden = !visible; + $("btn-ai-toggle").classList.toggle("btn-active", visible); +} + +function updateCostDisplay() { + const el = $("chat-cost"); + if (chatTotalCost === 0) { + el.textContent = ""; + return; + } + const cost = chatTotalCost < 0.01 + ? `<$0.01` + : `$${chatTotalCost.toFixed(4)}`; + el.textContent = `${cost} · ${chatInputTokens.toLocaleString()} in / ${chatOutputTokens.toLocaleString()} out`; +} + +function appendChatMessage(role: "user" | "assistant" | "system", text: string) { + const container = $("chat-messages"); + const div = document.createElement("div"); + div.className = `chat-msg chat-msg-${role}`; + const pre = document.createElement("pre"); + pre.className = "chat-msg-text"; + pre.textContent = text; + div.appendChild(pre); + container.appendChild(div); + container.scrollTop = container.scrollHeight; +} + +async function sendChat() { + const input = $("chat-input") as HTMLTextAreaElement; + const text = input.value.trim(); + if (!text) return; + + input.value = ""; + input.disabled = true; + ($("btn-chat-send") as HTMLButtonElement).disabled = true; + + chatHistory.push({ role: "user", content: text }); + appendChatMessage("user", text); + + const fileContent = model.getValue(); + + try { + const resp = (await Chat(chatHistory, fileContent)) as ChatResponse; + chatHistory.push({ role: "assistant", content: resp.content }); + appendChatMessage("assistant", resp.content); + chatTotalCost += resp.costUSD; + chatInputTokens += resp.inputTokens; + chatOutputTokens += resp.outputTokens; + updateCostDisplay(); + } catch (err) { + chatHistory.pop(); // remove failed user msg from history + appendChatMessage("system", `Error: ${err}`); + } finally { + input.disabled = false; + ($("btn-chat-send") as HTMLButtonElement).disabled = false; + input.focus(); + } +} + +$("btn-ai-toggle").addEventListener("click", () => showChatPane(!chatVisible)); + +$("btn-chat-send").addEventListener("click", sendChat); + +$("chat-input").addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendChat(); + } +}); + +$("btn-chat-clear").addEventListener("click", () => { + chatHistory = []; + chatTotalCost = 0; + chatInputTokens = 0; + chatOutputTokens = 0; + $("chat-messages").innerHTML = ""; + updateCostDisplay(); +}); + +$("btn-chat-key").addEventListener("click", openApiKeyOverlay); + +// ---- API key overlay ---------------------------------------------------- + +async function openApiKeyOverlay() { + try { + const key = (await GetAPIKey()) as string; + ($("api-key-input") as HTMLInputElement).value = key || ""; + } catch { + ($("api-key-input") as HTMLInputElement).value = ""; + } + $("api-key-overlay").hidden = false; +} + +function closeApiKeyOverlay() { + $("api-key-overlay").hidden = true; +} + +$("btn-api-key-close").addEventListener("click", closeApiKeyOverlay); +$("api-key-overlay").addEventListener("click", (e) => { + if (e.target === $("api-key-overlay")) closeApiKeyOverlay(); +}); + +$("btn-api-key-save").addEventListener("click", async () => { + const key = ($("api-key-input") as HTMLInputElement).value.trim(); + try { + await SetAPIKey(key); + toast(key ? "API key saved to keychain." : "API key cleared.", "success"); + closeApiKeyOverlay(); + } catch (err) { + toast(`Failed to save key: ${err}`, "error"); + } +}); + +$("btn-api-key-delete").addEventListener("click", async () => { + try { + await DeleteAPIKey(); + ($("api-key-input") as HTMLInputElement).value = ""; + toast("API key removed from keychain.", "success"); + closeApiKeyOverlay(); + } catch (err) { + toast(`Failed to remove key: ${err}`, "error"); + } +}); + +// Keyboard shortcut ⌘⇧A to toggle chat +// (wired into the existing keydown handler via the 'a' case) diff --git a/studio/frontend/src/style.css b/studio/frontend/src/style.css index 5dee0da..b1a1c03 100644 --- a/studio/frontend/src/style.css +++ b/studio/frontend/src/style.css @@ -440,3 +440,79 @@ kbd { ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 5px; border: 2px solid var(--bg-1); } ::-webkit-scrollbar-thumb:hover { background: var(--border-strong); } + +/* ------------------------------------------------------------------ + AI Chat pane (bottom panel, outside the grid) +------------------------------------------------------------------ */ +#chat-pane { + flex-shrink: 0; + height: 260px; + display: flex; + flex-direction: column; + background: var(--bg-1); + border-top: 1px solid var(--border); +} +#chat-pane[hidden] { display: none; } + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.chat-msg { display: flex; flex-direction: column; max-width: 90%; } +.chat-msg-user { align-self: flex-end; } +.chat-msg-assistant { align-self: flex-start; } +.chat-msg-system { align-self: center; opacity: 0.6; } + +.chat-msg-text { + margin: 0; + font: 13px/1.5 ui-monospace, SF Mono, Menlo, monospace; + white-space: pre-wrap; + word-break: break-word; + padding: 6px 10px; + border-radius: var(--radius-sm); + background: var(--bg-2); +} +.chat-msg-user .chat-msg-text { + background: var(--brand-strong); + color: white; + font-family: inherit; + font-size: 13px; + white-space: pre-wrap; +} +.chat-msg-system .chat-msg-text { font-size: 11px; font-family: inherit; } + +.chat-input-row { + display: flex; + gap: 6px; + padding: 6px 8px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +.chat-input { + flex: 1; + background: var(--bg-2); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 10px; + font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; + resize: none; + outline: none; +} +.chat-input:focus { border-color: var(--brand); } + +.chat-send-btn { align-self: flex-end; padding: 7px 12px; } + +.chat-cost { + font-size: 11px; + flex: 1; + text-align: center; +} + +.btn-active { background: var(--brand-strong); border-color: var(--brand-strong); color: white; } diff --git a/studio/frontend/wailsjs/go/main/App.d.ts b/studio/frontend/wailsjs/go/main/App.d.ts index fd2ab03..4f914ee 100755 --- a/studio/frontend/wailsjs/go/main/App.d.ts +++ b/studio/frontend/wailsjs/go/main/App.d.ts @@ -2,8 +2,14 @@ // This file is automatically generated. DO NOT EDIT import {main} from '../models'; +export function Chat(arg1:Array,arg2:string):Promise; + export function Connect(arg1:string):Promise; +export function DeleteAPIKey():Promise; + +export function GetAPIKey():Promise; + export function ConnectWiFi(arg1:string,arg2:number,arg3:string):Promise; export function Disconnect():Promise; @@ -26,6 +32,8 @@ export function RunFile(arg1:string):Promise>; export function SaveWorkspaceSettings(arg1:string,arg2:main.WorkspaceSettings):Promise; +export function SetAPIKey(arg1:string):Promise; + export function StartRecording():Promise; export function StartWiFiDiscovery():Promise; diff --git a/studio/frontend/wailsjs/go/main/App.js b/studio/frontend/wailsjs/go/main/App.js index e660275..787ba9c 100755 --- a/studio/frontend/wailsjs/go/main/App.js +++ b/studio/frontend/wailsjs/go/main/App.js @@ -2,10 +2,22 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function Chat(arg1, arg2) { + return window['go']['main']['App']['Chat'](arg1, arg2); +} + export function Connect(arg1) { return window['go']['main']['App']['Connect'](arg1); } +export function DeleteAPIKey() { + return window['go']['main']['App']['DeleteAPIKey'](); +} + +export function GetAPIKey() { + return window['go']['main']['App']['GetAPIKey'](); +} + export function ConnectWiFi(arg1, arg2, arg3) { return window['go']['main']['App']['ConnectWiFi'](arg1, arg2, arg3); } @@ -50,6 +62,10 @@ export function SaveWorkspaceSettings(arg1, arg2) { return window['go']['main']['App']['SaveWorkspaceSettings'](arg1, arg2); } +export function SetAPIKey(arg1) { + return window['go']['main']['App']['SetAPIKey'](arg1); +} + export function StartRecording() { return window['go']['main']['App']['StartRecording'](); } diff --git a/studio/frontend/wailsjs/go/models.ts b/studio/frontend/wailsjs/go/models.ts index c2fad99..63033de 100755 --- a/studio/frontend/wailsjs/go/models.ts +++ b/studio/frontend/wailsjs/go/models.ts @@ -1,5 +1,37 @@ export namespace main { - + + export class ChatMessage { + role: string; + content: string; + + static createFrom(source: any = {}) { + return new ChatMessage(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.role = source["role"]; + this.content = source["content"]; + } + } + export class ChatResponse { + content: string; + inputTokens: number; + outputTokens: number; + costUSD: number; + + static createFrom(source: any = {}) { + return new ChatResponse(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.content = source["content"]; + this.inputTokens = source["inputTokens"]; + this.outputTokens = source["outputTokens"]; + this.costUSD = source["costUSD"]; + } + } export class ConnectionStatus { connected: boolean; deviceId: string; diff --git a/studio/go.mod b/studio/go.mod index caa6f3a..eca101e 100644 --- a/studio/go.mod +++ b/studio/go.mod @@ -17,8 +17,9 @@ require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect @@ -40,6 +41,7 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/studio/go.sum b/studio/go.sum index 9cf7df2..8545d76 100644 --- a/studio/go.sum +++ b/studio/go.sum @@ -4,12 +4,16 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -71,6 +75,8 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= diff --git a/studio/keychain.go b/studio/keychain.go new file mode 100644 index 0000000..9d9211e --- /dev/null +++ b/studio/keychain.go @@ -0,0 +1,12 @@ +package main + +import "github.com/zalando/go-keyring" + +const ( + keychainService = "flutterprobe.studio" + keychainUser = "anthropic-api-key" +) + +func keychainGet() (string, error) { return keyring.Get(keychainService, keychainUser) } +func keychainSet(key string) error { return keyring.Set(keychainService, keychainUser, key) } +func keychainDelete() error { return keyring.Delete(keychainService, keychainUser) }