From 87d7739b4048dee3c25658e341b4ccdfa0a29c39 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 24 Sep 2026 15:36:52 +0200 Subject: [PATCH] feat: remove Ask why FTW's core is the local EMS and planner. Ask why sent the site's help report to OpenRouter and chatted about it; that belongs outside Core, in whatever agent the owner chooses, reading the same help report and API. The help report, planner reasons and diagnostics stay. Removes the assistant package, the /api/assistant/* routes, the Settings fieldset, the Plan card question box, the header chip, the ask_why issue template and the config schema block. The support dump keeps its IPv4 and Bearer redaction, now in the api package. Stored state: on start, Core rewrites stored settings that still carry the assistant block, so the OpenRouter key leaves state.db and later backups. The assistant_threads table stays, unused, like the Home Link tables; the state schema version does not change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 --- .changeset/remove-ask-why.md | 8 + .github/ISSUE_TEMPLATE/ask_why.md | 5 - config.example.yaml | 11 - docs/roadmap.md | 2 +- go/cmd/ftw/http_server.go | 3 +- go/cmd/ftw/main.go | 5 + go/internal/api/api.go | 11 - go/internal/api/api_assistant.go | 291 ------- go/internal/api/api_assistant_test.go | 477 ----------- go/internal/api/api_assistant_threads.go | 159 ---- go/internal/api/api_assistant_threads_test.go | 300 ------- go/internal/api/api_assistant_tools.go | 264 ------ go/internal/api/api_config_apply_test.go | 17 +- go/internal/api/api_drivers_debug.go | 16 +- go/internal/api/api_drivers_debug_test.go | 4 +- go/internal/api/security.go | 6 - go/internal/api/security_test.go | 9 - go/internal/assistant/assistant.go | 539 ------------- go/internal/assistant/assistant_test.go | 408 ---------- go/internal/assistant/skill.go | 31 - go/internal/assistant/tools.go | 73 -- go/internal/config/config.go | 85 -- go/internal/config/config_test.go | 38 - go/internal/config/restart_required_test.go | 9 - go/internal/config/storage.go | 21 + go/internal/config/storage_test.go | 63 ++ go/internal/state/assistant_threads.go | 187 ----- go/internal/state/assistant_threads_test.go | 178 ----- go/internal/state/store.go | 18 +- web/app.css | 62 -- web/app.js | 2 - web/assistant.js | 756 ------------------ web/assistant.test.mjs | 320 -------- web/help-report.test.mjs | 1 - web/index.html | 30 +- web/settings/tabs/system.js | 39 +- web/settings/tabs/system.test.mjs | 19 +- 37 files changed, 134 insertions(+), 4333 deletions(-) create mode 100644 .changeset/remove-ask-why.md delete mode 100644 .github/ISSUE_TEMPLATE/ask_why.md delete mode 100644 go/internal/api/api_assistant.go delete mode 100644 go/internal/api/api_assistant_test.go delete mode 100644 go/internal/api/api_assistant_threads.go delete mode 100644 go/internal/api/api_assistant_threads_test.go delete mode 100644 go/internal/api/api_assistant_tools.go delete mode 100644 go/internal/assistant/assistant.go delete mode 100644 go/internal/assistant/assistant_test.go delete mode 100644 go/internal/assistant/skill.go delete mode 100644 go/internal/assistant/tools.go delete mode 100644 go/internal/state/assistant_threads.go delete mode 100644 go/internal/state/assistant_threads_test.go delete mode 100644 web/assistant.js delete mode 100644 web/assistant.test.mjs diff --git a/.changeset/remove-ask-why.md b/.changeset/remove-ask-why.md new file mode 100644 index 000000000..6493e97dd --- /dev/null +++ b/.changeset/remove-ask-why.md @@ -0,0 +1,8 @@ +--- +"ftw": minor +--- + +Ask why is removed: the Plan card question box, the header chip, the Settings +fieldset and the `/api/assistant/*` routes. On first start, Core deletes the +stored Ask why settings and OpenRouter key. Old conversations stay unused in +state.db. The help report is unchanged and is again the one button under the plan. diff --git a/.github/ISSUE_TEMPLATE/ask_why.md b/.github/ISSUE_TEMPLATE/ask_why.md deleted file mode 100644 index a5a73e8b8..000000000 --- a/.github/ISSUE_TEMPLATE/ask_why.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: Ask why -about: A filled diagnosis from the FTW box. One text field. -labels: bug ---- diff --git a/config.example.yaml b/config.example.yaml index 99f633c06..e346fa3ab 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -229,17 +229,6 @@ fleet_ping: enabled: true # endpoint: https://relay.ftw.energy/fleet -# Ask why — optional. Paste an OpenRouter key (free account, no card) and -# the Plan card can explain the live help report and draft a GitHub issue. -# Off until enabled. The key is stored on this box and sent only as a -# bearer token to the API you set. Default model openrouter/free stays on -# free models. The helper never issues driver commands. -# assistant: -# enabled: true -# api_key: sk-or-v1-... -# model: openrouter/free -# # base_url: https://openrouter.ai/api/v1 - # Independently distributed Lua drivers. The signed stable catalog is refreshed # by default, but refresh never downloads, activates, or restarts a driver. # Add `device_repository: { enabled: false }` to opt out. The expanded form is: diff --git a/docs/roadmap.md b/docs/roadmap.md index 1833cdb3f..987181212 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -121,7 +121,7 @@ change does not prove the currently pinned recovery bundle passes. | Daily charging | [The webapp panel](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/EvPanel.svelte) already saves SoC on slider release, changes schedules without a Save button and supports Charge now. | [Now](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/Now.svelte) normally opens that panel after a charger tap or notification link. Bring the relevant SoC action directly into the post-plug-in entry experience. Complete the goal → plan → delivered-energy flow on real chargers, including offline cars and restarts. | | Notifications | Core and the webapp implement subscription, charging connection/completion/interruption events and device alerts. See the [shared push catalogue](../contract/push-catalogue.yaml) and [rule defaults](../go/internal/notifications/service.go). | There is no dedicated predicted-missed-departure event in that catalogue. Add an actionable goal-risk notification and make activation clear during charging setup, with user consent. Verify delivery while the app is closed; an interrupted-session alert alone does not cover a future shortfall. | | Live trust and expert access | Flow exists. [LivePanel](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/LivePanel.svelte) already puts a recent one-second trace behind each energy bubble and freezes it on silence. Core stores [structured v2 command results](../go/internal/state/driver_command_results.go), plan diagnostics and issued forecasts. | Join request, accepted intent, command response and measured effect in the normal experience and a structured analysis API, including legacy drivers and different sample cadences. Current result records and live watts are useful parts, not a complete proof of causality. Measure response time on a target box. | -| External control and agents | [Protocol command IDs and authorization leases](../go/internal/appproto/command.go), scoped operations, [bounded battery holds](../go/internal/api/api_battery_manual.go), schedule APIs and encrypted sessions exist. [HASS callbacks](../go/cmd/ftw/main.go) persist modes and grid targets. The built-in [Ask why tools](../go/internal/api/api_assistant_tools.go) are read-only. | Define renewable external control separately from durable goals. Losing HASS does not currently expire its saved mode. Existing authorization leases do not supply that policy. Build structured agent reads first, then permitted schedule/plan writes and a cloud MCP endpoint using the same Core checks. | +| External control and agents | [Protocol command IDs and authorization leases](../go/internal/appproto/command.go), scoped operations, [bounded battery holds](../go/internal/api/api_battery_manual.go), schedule APIs and encrypted sessions exist. [HASS callbacks](../go/cmd/ftw/main.go) persist modes and grid targets. | Define renewable external control separately from durable goals. Losing HASS does not currently expire its saved mode. Existing authorization leases do not supply that policy. Build structured agent reads first, then permitted schedule/plan writes and a cloud MCP endpoint using the same Core checks. | | Savings | [The API](../go/internal/api/api_savings.go) explicitly reports `site_total` against `no_pv_no_battery_vehicle_energy_at_daily_average`. Actual import cost and export revenue are available. | Make the scope clear on each surface that says “saved”. Then add and validate the same-hardware self-consumption counterfactual, including EV behaviour and stored-energy accounting. Do not relabel the current figure as FTW's incremental benefit. | | Heat and settings | Thermal contracts and an [explicitly opted-in solar feed](../go/cmd/ftw/solar_feed_send.go) already exist. The on-box [planner settings](../web/settings/tabs/planner.js) and webapp use different levels of technical language; the on-box minimum SoC still says “House reserve”. | Keep existing opt-ins explicit while phase one uses heat data for planning. Align basic controls around user goals and distinguish operating limits from forecast caution. Audit stored settings before removing or hiding them. Active tank/hot-water optimization remains a later bounded outcome. | diff --git a/go/cmd/ftw/http_server.go b/go/cmd/ftw/http_server.go index 43789bcb9..92f8673f8 100644 --- a/go/cmd/ftw/http_server.go +++ b/go/cmd/ftw/http_server.go @@ -8,8 +8,7 @@ import ( const ( httpReadHeaderTimeout = 10 * time.Second httpReadTimeout = 15 * time.Second - // WriteTimeout must outlast assistant.Timeout (90s): Ask why streams SSE - // on this listener, and a shorter write ceiling would cut the reply off. + // WriteTimeout bounds how long one response may take to a slow client. httpWriteTimeout = 2 * time.Minute httpIdleTimeout = 60 * time.Second ) diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index d4b9fd257..033afa5f9 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -526,6 +526,11 @@ func main() { slog.Error("initialize config database", "err", err) os.Exit(1) } + if dropped, err := config.DropRetiredSettings(st, *configPath, cfg); err != nil { + slog.Warn("could not remove retired Ask why settings", "err", err) + } else if dropped { + slog.Info("Ask why has been removed; its settings and API key were deleted") + } if cfg.State != nil && cfg.State.ColdRetentionDays != 0 { slog.Warn("state.cold_retention_days is retired; fixed EMS history retention applies", "previous_days", cfg.State.ColdRetentionDays) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 262c983fa..e46435d8e 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -232,10 +232,6 @@ type Deps struct { Bundle *components.Bundle Version string - - // AssistantHTTP is the outbound client for Ask why. Nil uses a - // client with assistant.Timeout. Tests inject httptest.Server's client. - AssistantHTTP *http.Client } // Server wraps the http.ServeMux and adds shared middleware (logging, @@ -281,7 +277,6 @@ type Server struct { versionUpdateMu sync.Mutex driverUpdateMu sync.Mutex backupMu sync.Mutex - assistantAskMu sync.Mutex // Timers that put a driver back after an edit has been tried for its // window. The record on disk is what survives a restart; these only make @@ -447,12 +442,6 @@ func (s *Server) routes() { s.handle("GET /api/support/dump", Local, s.handleSupportDump) s.handle("GET /api/ocpp/chargers", Local, s.handleOCPPChargers) s.handle("GET /api/support/report", Local, s.handleSupportReport) - s.handle("GET /api/assistant/status", Read, s.handleAssistantStatus) - s.handle("POST /api/assistant/ask", Local, s.handleAssistantAsk) - s.handle("GET /api/assistant/threads", Read, s.handleAssistantThreads) - s.handle("GET /api/assistant/threads/{id}", Read, s.handleAssistantThread) - s.handle("DELETE /api/assistant/threads/{id}", Configure, s.handleAssistantThreadDelete) - s.handle("DELETE /api/assistant/threads", Configure, s.handleAssistantThreadsClear) s.handle("POST /api/drivers/{name}/control", Actuate, s.handleDriverControl) s.handle("DELETE /api/drivers/{name}/control", Actuate, s.handleDriverControlRelease) s.handle("POST /api/drivers/{name}/restart", Configure, s.handleDriverRestart) diff --git a/go/internal/api/api_assistant.go b/go/internal/api/api_assistant.go deleted file mode 100644 index 0551f8f01..000000000 --- a/go/internal/api/api_assistant.go +++ /dev/null @@ -1,291 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "log/slog" - "net/http" - "net/url" - "strings" - "time" - "unicode/utf8" - - "github.com/srcfl/ftw/go/internal/assistant" - "github.com/srcfl/ftw/go/internal/config" -) - -// githubNewIssueURL is a markdown template with one body field. The YAML -// bug form is not used: it asks the operator to fill steps the diagnosis -// already wrote. -const githubNewIssueURL = "https://github.com/srcfl/ftw/issues/new?template=ask_why.md" -const githubIssueURLBudget = 7000 - -type assistantAskRequest struct { - Question string `json:"question"` - Trigger *assistantTrigger `json:"trigger,omitempty"` - History []assistant.Turn `json:"history,omitempty"` - // ThreadID continues a stored conversation. Empty starts a new one. - ThreadID string `json:"thread_id,omitempty"` -} - -type assistantTrigger struct { - Kind string `json:"kind"` - Driver string `json:"driver,omitempty"` -} - -type assistantAskResponse struct { - Answer string `json:"answer"` - IssueTitle string `json:"issue_title,omitempty"` - IssueBody string `json:"issue_body,omitempty"` - IssueURL string `json:"issue_url,omitempty"` - Model string `json:"model"` - ResolvedModel string `json:"resolved_model,omitempty"` - ThreadID string `json:"thread_id,omitempty"` -} - -type assistantStatusResponse struct { - Enabled bool `json:"enabled"` - Configured bool `json:"configured"` - Ready bool `json:"ready"` - Model string `json:"model"` - BaseURLHost string `json:"base_url_host,omitempty"` - SetupURL string `json:"setup_url,omitempty"` - Unavailable string `json:"unavailable,omitempty"` -} - -func (s *Server) assistantSnapshot() config.Assistant { - if s.deps.Cfg == nil { - return config.Assistant{} - } - if s.deps.CfgMu != nil { - s.deps.CfgMu.RLock() - defer s.deps.CfgMu.RUnlock() - } - if s.deps.Cfg.Assistant == nil { - return config.Assistant{} - } - return *s.deps.Cfg.Assistant -} - -func (s *Server) handleAssistantStatus(w http.ResponseWriter, r *http.Request) { - asst := s.assistantSnapshot() - host := "" - if u, err := url.Parse(asst.ResolvedBaseURL()); err == nil { - host = u.Host - } - out := assistantStatusResponse{ - Enabled: asst.Enabled, - Configured: strings.TrimSpace(asst.APIKey) != "", - Ready: asst.Ready(), - Model: asst.ResolvedModel(), - BaseURLHost: host, - SetupURL: "https://openrouter.ai/keys", - } - if !asst.Enabled && strings.TrimSpace(asst.APIKey) == "" { - out.Unavailable = "Paste an OpenRouter key in Settings → System. That turns Ask why on." - } else if !asst.Enabled { - out.Unavailable = "Turn on Ask why in Settings → System." - } else if strings.TrimSpace(asst.APIKey) == "" { - out.Unavailable = "Paste an OpenRouter API key in Settings → System. A free key is enough." - } - writeJSON(w, http.StatusOK, out) -} - -func (s *Server) handleAssistantAsk(w http.ResponseWriter, r *http.Request) { - if !s.assistantAskMu.TryLock() { - writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Ask why is already running"}) - return - } - defer s.assistantAskMu.Unlock() - - var body assistantAskRequest - if err := readJSON(r, &body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) - return - } - if utf8.RuneCountInString(body.Question) > 2000 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "question is too long"}) - return - } - - asst := s.assistantSnapshot() - if !asst.Ready() { - msg := "Ask why is off. Turn it on in Settings → System and paste an OpenRouter key." - if asst.Enabled && strings.TrimSpace(asst.APIKey) == "" { - msg = "Ask why needs an OpenRouter API key in Settings → System." - } - writeJSON(w, http.StatusConflict, map[string]string{"error": msg}) - return - } - - if s.deps.Ctrl == nil || s.deps.Tel == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "site state is not available"}) - return - } - - stream := strings.Contains(r.Header.Get("Accept"), "text/event-stream") - var flush http.Flusher - if stream { - var ok bool - flush, ok = w.(http.Flusher) - if !ok { - stream = false - } - } - progress := func(kind, text string) { - if !stream { - return - } - writeAssistantSSE(w, flush, map[string]string{"type": kind, "text": text}) - } - - start := time.Now() - progress("status", "Reading the site") - facts := s.assistantFacts() - // Complete emits "Asking the model" once per round. Saying it here - // too would double every line in the activity log. - cli := &assistant.Client{HTTP: s.deps.AssistantHTTP} - reply, err := cli.Complete(r.Context(), assistant.Request{ - APIKey: asst.APIKey, - Model: asst.ResolvedModel(), - BaseURL: asst.ResolvedBaseURL(), - Question: body.Question, - Trigger: formatAssistantTrigger(body.Trigger), - History: body.History, - Snapshot: facts, - Run: s.runAssistantTool, - Progress: progress, - }) - if err != nil { - var apiErr *assistant.APIError - if errors.As(err, &apiErr) { - slog.Warn("assistant ask failed", "status", apiErr.Status, "err", apiErr.Msg) - if stream { - writeAssistantSSE(w, flush, map[string]string{"type": "error", "error": apiErr.Msg}) - return - } - writeJSON(w, apiErr.Status, map[string]string{"error": apiErr.Msg}) - return - } - slog.Warn("assistant ask failed", "err", err) - if stream { - writeAssistantSSE(w, flush, map[string]string{"type": "error", "error": "could not reach the model API"}) - return - } - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "could not reach the model API"}) - return - } - - out := assistantAskResponse{ - Answer: reply.Answer, - IssueTitle: reply.IssueTitle, - IssueBody: reply.IssueBody, - Model: reply.Model, - ResolvedModel: reply.ResolvedModel, - } - if out.IssueTitle != "" && out.IssueBody == "" { - out.IssueBody = out.Answer - } - if out.IssueTitle != "" { - out.IssueURL = filledIssueURL(out.IssueTitle, out.IssueBody) - } - out.ThreadID = s.recordAssistantTurn(body, out.Answer, out.ResolvedModel) - slog.Info("assistant ask", - "model", out.ResolvedModel, - "ms", time.Since(start).Milliseconds(), - "tools", reply.ToolRounds, - "issue", out.IssueTitle != "") - if stream { - writeAssistantSSE(w, flush, map[string]any{ - "type": "done", - "answer": out.Answer, - "issue_title": out.IssueTitle, - "issue_body": out.IssueBody, - "issue_url": out.IssueURL, - "model": out.Model, - "resolved_model": out.ResolvedModel, - "thread_id": out.ThreadID, - }) - return - } - writeJSON(w, http.StatusOK, out) -} - -func filledIssueURL(title, body string) string { - if utf8.RuneCountInString(body) > 4000 { - body = string([]rune(body)[:4000]) + "\n\n[truncated]" - } - u := githubNewIssueURL + "&title=" + url.QueryEscape(title) + "&body=" + url.QueryEscape(body) - if len(u) <= githubIssueURLBudget { - return u - } - return githubNewIssueURL + "&title=" + url.QueryEscape(title) -} - -func writeAssistantSSE(w http.ResponseWriter, flush http.Flusher, v any) { - if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-Accel-Buffering", "no") - w.WriteHeader(http.StatusOK) - } - raw, err := json.Marshal(v) - if err != nil { - return - } - _, _ = w.Write([]byte("data: ")) - _, _ = w.Write(raw) - _, _ = w.Write([]byte("\n\n")) - if flush != nil { - flush.Flush() - } -} - -func (s *Server) assistantFacts() string { - var b strings.Builder - v := strings.TrimSpace(s.deps.Version) - if v == "" { - v = "dev" - } - b.WriteString("FTW version ") - b.WriteString(v) - b.WriteString("\n\n") - b.WriteString(s.toolDriverHealth(nil)) - b.WriteString("\n\n") - b.WriteString(s.toolPlanNow()) - b.WriteString("\n\n") - b.WriteString(s.toolRecentLogs([]byte(`{"limit":20}`))) - return assistant.Redact(b.String()) -} - -func formatAssistantTrigger(t *assistantTrigger) string { - if t == nil { - return "" - } - kind := strings.TrimSpace(t.Kind) - driver := sanitizeDriverName(t.Driver) - if kind == "plan" { - return "the operator is asking why the current plan looks like this" - } - if kind == "driver_offline" && driver != "" { - return "driver " + driver + " is offline" - } - if kind == "driver_offline" { - return "a driver is offline" - } - return "" -} - -func sanitizeDriverName(name string) string { - name = strings.TrimSpace(name) - if name == "" || len(name) > 40 { - return "" - } - for _, r := range name { - if r == '_' || r == '-' || r == '.' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - continue - } - return "" - } - return name -} diff --git a/go/internal/api/api_assistant_test.go b/go/internal/api/api_assistant_test.go deleted file mode 100644 index c1f30f18e..000000000 --- a/go/internal/api/api_assistant_test.go +++ /dev/null @@ -1,477 +0,0 @@ -package api - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "github.com/srcfl/ftw/go/internal/apiauth" - "github.com/srcfl/ftw/go/internal/assistant" - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/control" - "github.com/srcfl/ftw/go/internal/mpc" - "github.com/srcfl/ftw/go/internal/telemetry" -) - -func assistantTestServer(t *testing.T, asst *config.Assistant, httpClient *http.Client) *Server { - t.Helper() - st := control.NewState(0, 50, "meter") - tel := telemetry.NewStore() - tel.DriverHealthMut("meter").RecordSuccess() - tel.Update("meter", telemetry.DerMeter, 500, nil, nil) - return New(&Deps{ - Ctrl: st, - CtrlMu: &sync.Mutex{}, - Tel: tel, - Cfg: &config.Config{Assistant: asst}, - CfgMu: &sync.RWMutex{}, - Version: "test-version", - AssistantHTTP: httpClient, - }) -} - -func postAssistantAsk(question string) *http.Request { - body := `{"question":` + jsonString(question) + `}` - req := httptest.NewRequest(http.MethodPost, "/api/assistant/ask", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - return req -} - -func jsonString(s string) string { - b, _ := json.Marshal(s) - return string(b) -} - -func TestAssistantAskIsLocal(t *testing.T) { - srv := assistantTestServer(t, nil, nil) - req := postAssistantAsk("") - if facts := srv.Route(req); facts.Tier != apiauth.TierLocal { - t.Fatalf("tier = %v, want Local — this path ships the help report off-box", facts.Tier) - } -} - -func TestAssistantStatusIsRead(t *testing.T) { - srv := assistantTestServer(t, nil, nil) - req := httptest.NewRequest(http.MethodGet, "/api/assistant/status", nil) - if facts := srv.Route(req); facts.Tier != apiauth.TierRead { - t.Fatalf("tier = %v, want Read", facts.Tier) - } -} - -func TestAssistantStatusWithoutConfig(t *testing.T) { - srv := assistantTestServer(t, nil, nil) - req := httptest.NewRequest(http.MethodGet, "/api/assistant/status", nil) - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) - } - var got assistantStatusResponse - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatal(err) - } - if got.Ready || got.Enabled || got.Configured { - t.Fatalf("empty config should be unavailable: %+v", got) - } - if got.Model != config.DefaultAssistantModel { - t.Fatalf("model = %q", got.Model) - } - if !strings.Contains(got.Unavailable, "Paste an OpenRouter key") { - t.Fatalf("unavailable = %q", got.Unavailable) - } -} - -func TestAssistantAskWithoutKey(t *testing.T) { - srv := assistantTestServer(t, &config.Assistant{Enabled: true}, nil) - req := postAssistantAsk("why?") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) - } - if strings.Contains(rec.Body.String(), "sk-or-") { - t.Fatal("response leaked a key") - } -} - -func TestAssistantAskExplainsReport(t *testing.T) { - var sawPrompt string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - sawPrompt = string(raw) - _ = json.NewEncoder(w).Encode(map[string]any{ - "model": "meta-llama/llama-3.3-70b-instruct:free", - "choices": []map[string]any{ - {"message": map[string]string{ - "role": "assistant", - "content": "## Answer\nThe site meter is live and the house is importing about 500 W.\n\n## Issue title\n[bug] meter idle mismatch\n\n## Issue body\nFTW test-version. Site meter reads 500 W import while the plan expected idle.\nTalks to 10.0.0.5.\n", - }}, - }, - }) - })) - defer upstream.Close() - - asst := &config.Assistant{ - Enabled: true, - APIKey: "sk-or-v1-secret-key", - Model: "openrouter/free", - BaseURL: upstream.URL, - } - srv := assistantTestServer(t, asst, upstream.Client()) - req := postAssistantAsk("why import?") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) - } - if !strings.Contains(sawPrompt, "why import?") { - t.Fatal("prompt missing the question") - } - if !strings.Contains(sawPrompt, `"name":"get_support_report"`) { - t.Fatalf("prompt missing tools: %s", sawPrompt) - } - if strings.Contains(sawPrompt, "sk-or-v1-secret-key") { - t.Fatal("API key was sent in the prompt body") - } - - var got assistantAskResponse - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatal(err) - } - if !strings.Contains(got.Answer, "500 W") { - t.Fatalf("answer = %q", got.Answer) - } - if got.IssueTitle != "[bug] meter idle mismatch" { - t.Fatalf("title = %q", got.IssueTitle) - } - if strings.Contains(got.IssueBody, "10.0.0.5") { - t.Fatalf("issue body kept an IP: %q", got.IssueBody) - } - if !strings.Contains(got.IssueURL, "github.com/srcfl/ftw/issues/new") { - t.Fatalf("issue url = %q", got.IssueURL) - } - if !strings.Contains(got.IssueURL, "template=ask_why.md") { - t.Fatalf("issue url used the YAML form: %q", got.IssueURL) - } - if strings.Contains(got.IssueURL, "bug_report.yml") { - t.Fatalf("issue url used the YAML form: %q", got.IssueURL) - } - if !strings.Contains(got.IssueURL, "&body=") { - t.Fatalf("issue url missing filled body: %q", got.IssueURL) - } - if strings.Contains(rec.Body.String(), "sk-or-v1-secret-key") { - t.Fatal("API key leaked into the HTTP response") - } - if strings.Contains(got.Answer, "# FTW help report") { - t.Fatal("response echoed the raw help report") - } -} - -func TestAssistantAskRejectsSecondCall(t *testing.T) { - started := make(chan struct{}) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(started) - time.Sleep(200 * time.Millisecond) - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "## Answer\nok\n"}}, - }, - }) - })) - defer upstream.Close() - asst := &config.Assistant{Enabled: true, APIKey: "k", BaseURL: upstream.URL} - srv := assistantTestServer(t, asst, upstream.Client()) - - done := make(chan int, 1) - go func() { - req := postAssistantAsk("one") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - done <- rec.Code - }() - <-started - req := postAssistantAsk("two") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusTooManyRequests { - t.Fatalf("second ask = %d body=%s", rec.Code, rec.Body.String()) - } - if code := <-done; code != http.StatusOK { - t.Fatalf("first ask = %d", code) - } -} - -func TestAssistantAskRunsSupportReportTool(t *testing.T) { - var rounds int - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - rounds++ - if strings.Contains(string(raw), `"role":"tool"`) { - if !strings.Contains(string(raw), "test-version") && !strings.Contains(string(raw), "FTW help report") { - t.Errorf("tool result missing help report: %s", raw) - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "## Answer\nReport read.\n"}}, - }, - }) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]any{ - "role": "assistant", - "tool_calls": []map[string]any{{ - "id": "c1", - "type": "function", - "function": map[string]string{"name": "get_support_report", "arguments": "{}"}, - }}, - }}, - }, - }) - })) - defer upstream.Close() - asst := &config.Assistant{Enabled: true, APIKey: "k", BaseURL: upstream.URL} - srv := assistantTestServer(t, asst, upstream.Client()) - req := postAssistantAsk("what is going on?") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) - } - if rounds != 2 { - t.Fatalf("rounds = %d, want 2", rounds) - } - var got assistantAskResponse - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatal(err) - } - if got.Answer != "Report read." { - t.Fatalf("answer = %q", got.Answer) - } -} - -func TestAssistantToolHealthAndLogs(t *testing.T) { - st := control.NewState(0, 50, "meter") - tel := telemetry.NewStore() - tel.DriverHealthMut("sungrow").SetOffline() - tel.DriverHealthMut("sungrow").RecordError("dial 10.0.0.5:502") - ring := telemetry.NewLogRing() - ring.Append(telemetry.LogEntry{TS: time.Now(), Level: "ERROR", Msg: "poll failed at 10.0.0.5", Driver: "sungrow"}) - ring.Append(telemetry.LogEntry{TS: time.Now(), Level: "INFO", Msg: "tick", Driver: "sungrow"}) - s := New(&Deps{ - Ctrl: st, CtrlMu: &sync.Mutex{}, Tel: tel, LogRing: ring, Version: "v-test", - }) - health, err := s.runAssistantTool(assistant.ToolDriverHealth, []byte(`{"name":"sungrow"}`)) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(health, "sungrow") || !strings.Contains(health, "offline") { - t.Fatalf("health = %q", health) - } - if strings.Contains(health, "10.0.0.5") { - t.Fatalf("health leaked an IP: %q", health) - } - logs, err := s.runAssistantTool(assistant.ToolRecentLogs, []byte(`{"driver":"sungrow"}`)) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(logs, "poll failed") { - t.Fatalf("logs = %q", logs) - } - if strings.Contains(logs, "10.0.0.5") { - t.Fatalf("logs leaked an IP: %q", logs) - } - if strings.Contains(logs, "tick") { - t.Fatalf("info lines should be filtered: %q", logs) - } - ver, err := s.runAssistantTool(assistant.ToolVersion, []byte(`{}`)) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(ver, "v-test") { - t.Fatalf("version = %q", ver) - } - planNow := s.toolPlanNow() - if !strings.Contains(planNow, "## Right now") || !strings.Contains(planNow, "## Plan") { - t.Fatalf("plan now = %q", planNow) - } -} - -func TestFilledIssueURLUsesMarkdownTemplate(t *testing.T) { - u := filledIssueURL("[bug] sungrow oauth", "FTW 2.12.0-beta.1\n\nmyuplink returned invalid_grant.") - if !strings.Contains(u, "template=ask_why.md") { - t.Fatalf("url = %q", u) - } - if strings.Contains(u, "bug_report.yml") { - t.Fatalf("url = %q", u) - } - if !strings.Contains(u, "&body=") { - t.Fatalf("url missing body: %q", u) - } -} - -func TestFilledIssueURLDropsBodyWhenOverBudget(t *testing.T) { - body := strings.Repeat("%", 4000) - u := filledIssueURL("title", body) - if strings.Contains(u, "&body=") { - t.Fatalf("expected title-only url, got len %d", len(u)) - } - if !strings.Contains(u, "template=ask_why.md") { - t.Fatalf("url = %q", u) - } -} - -func TestFormatAssistantTriggerPlan(t *testing.T) { - got := formatAssistantTrigger(&assistantTrigger{Kind: "plan"}) - if !strings.Contains(got, "current plan") { - t.Fatalf("got %q", got) - } -} - -func TestAssistantAskPlanTrigger(t *testing.T) { - var sawPrompt string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - sawPrompt = string(raw) - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "## Answer\nCharging because the next hours are cheap.\n"}}, - }, - }) - })) - defer upstream.Close() - asst := &config.Assistant{Enabled: true, APIKey: "k", BaseURL: upstream.URL} - srv := assistantTestServer(t, asst, upstream.Client()) - req := httptest.NewRequest(http.MethodPost, "/api/assistant/ask", strings.NewReader(`{"question":"why charge?","trigger":{"kind":"plan"}}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) - } - if !strings.Contains(sawPrompt, "why charge?") { - t.Fatal("prompt missing the question") - } - if !strings.Contains(sawPrompt, "current plan") { - t.Fatalf("prompt missing plan trigger: %s", sawPrompt) - } - if !strings.Contains(sawPrompt, "Hours ahead") && !strings.Contains(sawPrompt, "## Plan") { - t.Fatalf("prompt missing plan snapshot: %s", sawPrompt) - } -} - -func TestAssistantAskStreamsProgress(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "## Answer\nIdle as planned.\n"}}, - }, - }) - })) - defer upstream.Close() - asst := &config.Assistant{Enabled: true, APIKey: "k", BaseURL: upstream.URL} - srv := assistantTestServer(t, asst, upstream.Client()) - req := postAssistantAsk("why idle?") - req.Header.Set("Accept", "text/event-stream") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Header().Get("Content-Type"), "text/event-stream") { - t.Fatalf("content-type = %q", rec.Header().Get("Content-Type")) - } - body := rec.Body.String() - if !strings.Contains(body, `"type":"status"`) { - t.Fatalf("missing status event: %s", body) - } - if !strings.Contains(body, `"type":"done"`) { - t.Fatalf("missing done event: %s", body) - } - if !strings.Contains(body, "Idle as planned") { - t.Fatalf("missing answer: %s", body) - } -} - -func TestWritePlanAheadGroupsSlots(t *testing.T) { - now := time.Date(2026, 8, 31, 14, 0, 0, 0, time.UTC) - plan := &mpc.Plan{ - Actions: []mpc.Action{ - {SlotStartMs: now.UnixMilli(), SlotLenMin: 15, BatteryW: 3000, Reason: "cheap hour", PriceOre: 20, SoC: 0.50}, - {SlotStartMs: now.Add(15 * time.Minute).UnixMilli(), SlotLenMin: 15, BatteryW: 2800, Reason: "cheap hour", PriceOre: 22, SoC: 0.55}, - {SlotStartMs: now.Add(30 * time.Minute).UnixMilli(), SlotLenMin: 15, BatteryW: -2000, Reason: "expensive evening", PriceOre: 180, SoC: 0.40}, - }, - } - var b strings.Builder - writePlanAhead(&b, plan, now) - got := b.String() - if !strings.Contains(got, "## Hours ahead") { - t.Fatalf("got %q", got) - } - if !strings.Contains(got, "charge") || !strings.Contains(got, "cheap hour") { - t.Fatalf("missing charge block: %q", got) - } - if !strings.Contains(got, "discharge") || !strings.Contains(got, "expensive evening") { - t.Fatalf("missing discharge block: %q", got) - } -} - -func TestAssistantAskForwardsHistory(t *testing.T) { - var sawPrompt string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - sawPrompt = string(raw) - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "## Answer\nStill cheap.\n"}}, - }, - }) - })) - defer upstream.Close() - asst := &config.Assistant{Enabled: true, APIKey: "k", BaseURL: upstream.URL} - srv := assistantTestServer(t, asst, upstream.Client()) - body := `{"question":"what about tomorrow?","history":[{"role":"user","text":"why charge?"},{"role":"assistant","text":"Cheap hour."}]}` - req := httptest.NewRequest(http.MethodPost, "/api/assistant/ask", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) - } - if !strings.Contains(sawPrompt, "why charge?") || !strings.Contains(sawPrompt, "Cheap hour.") { - t.Fatalf("history missing: %s", sawPrompt) - } - if !strings.Contains(sawPrompt, "what about tomorrow?") { - t.Fatalf("question missing: %s", sawPrompt) - } -} - -func TestAssistantAskStreamsError(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":{"message":"key expired"}}`)) - })) - defer upstream.Close() - asst := &config.Assistant{Enabled: true, APIKey: "k", BaseURL: upstream.URL} - srv := assistantTestServer(t, asst, upstream.Client()) - req := postAssistantAsk("why idle?") - req.Header.Set("Accept", "text/event-stream") - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) - } - body := rec.Body.String() - if !strings.Contains(body, `"type":"error"`) { - t.Fatalf("missing error event: %s", body) - } - if strings.Contains(body, `"type":"done"`) { - t.Fatalf("done should not follow an error: %s", body) - } -} diff --git a/go/internal/api/api_assistant_threads.go b/go/internal/api/api_assistant_threads.go deleted file mode 100644 index 848b8dd71..000000000 --- a/go/internal/api/api_assistant_threads.go +++ /dev/null @@ -1,159 +0,0 @@ -package api - -import ( - "crypto/rand" - "encoding/hex" - "log/slog" - "net/http" - "strconv" - "strings" - "time" - - "github.com/srcfl/ftw/go/internal/state" -) - -// Ask why history. The box stores the conversations because the box is the -// record: a question asked from a laptop is readable from a phone, and a -// closed dialog no longer throws the answer away. - -const assistantThreadIDLen = 16 - -// newAssistantThreadID returns a random hex id. Ids are opaque to the UI. -func newAssistantThreadID() string { - var b [assistantThreadIDLen / 2]byte - if _, err := rand.Read(b[:]); err != nil { - return "" - } - return hex.EncodeToString(b[:]) -} - -// validAssistantThreadID accepts only what newAssistantThreadID produces, so -// a client cannot steer the id towards anything surprising. -func validAssistantThreadID(id string) bool { - id = strings.TrimSpace(id) - if len(id) != assistantThreadIDLen { - return false - } - for _, r := range id { - if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') { - continue - } - return false - } - return true -} - -// recordAssistantTurn appends the question and its answer to the thread the -// request names, or starts a new one. It returns the thread id the reply -// should carry; an empty string means nothing was stored. -// -// History comes from the request rather than from the row: the client is -// what decides which turns the model was given, and storage must not -// change that. Failing to store is not worth failing the answer over — the -// operator already has it on screen. -func (s *Server) recordAssistantTurn(req assistantAskRequest, answer, model string) string { - if s.deps.State == nil { - return "" - } - id := strings.TrimSpace(req.ThreadID) - started := int64(0) - var turns []state.AssistantTurn - if validAssistantThreadID(id) { - if prev, ok, err := s.deps.State.AssistantThreadByID(id); err == nil && ok { - turns = prev.Turns - started = prev.StartedMs - } else { - // The thread was pruned or never existed. Keep the id so the - // open dialog and the row agree from here on. - turns = nil - } - } else { - id = newAssistantThreadID() - } - if id == "" { - return "" - } - now := time.Now().UnixMilli() - turns = append(turns, - state.AssistantTurn{Role: "user", Text: req.Question, TsMs: now}, - state.AssistantTurn{Role: "assistant", Text: answer, TsMs: now}, - ) - if err := s.deps.State.SaveAssistantThread(state.AssistantThread{ - ID: id, - StartedMs: started, - Model: model, - Turns: turns, - }); err != nil { - slog.Warn("assistant thread not stored", "err", err) - return "" - } - return id -} - -func (s *Server) handleAssistantThreads(w http.ResponseWriter, r *http.Request) { - if s.deps.State == nil { - writeJSON(w, http.StatusOK, map[string]any{"threads": []state.AssistantThread{}}) - return - } - limit := 0 - if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { - limit, _ = strconv.Atoi(v) - } - threads, err := s.deps.State.RecentAssistantThreads(limit) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not read Ask why history"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"threads": threads}) -} - -func (s *Server) handleAssistantThread(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if !validAssistantThreadID(id) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad thread id"}) - return - } - if s.deps.State == nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such conversation"}) - return - } - t, ok, err := s.deps.State.AssistantThreadByID(id) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not read the conversation"}) - return - } - if !ok { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such conversation"}) - return - } - writeJSON(w, http.StatusOK, t) -} - -func (s *Server) handleAssistantThreadDelete(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if !validAssistantThreadID(id) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad thread id"}) - return - } - if s.deps.State == nil { - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) - return - } - if err := s.deps.State.DeleteAssistantThread(id); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not delete the conversation"}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) -} - -func (s *Server) handleAssistantThreadsClear(w http.ResponseWriter, r *http.Request) { - if s.deps.State == nil { - writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) - return - } - if err := s.deps.State.DeleteAllAssistantThreads(); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not clear Ask why history"}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) -} diff --git a/go/internal/api/api_assistant_threads_test.go b/go/internal/api/api_assistant_threads_test.go deleted file mode 100644 index c0efadc15..000000000 --- a/go/internal/api/api_assistant_threads_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "github.com/srcfl/ftw/go/internal/apiauth" - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/control" - "github.com/srcfl/ftw/go/internal/state" - "github.com/srcfl/ftw/go/internal/telemetry" -) - -// assistantHistoryServer is assistantTestServer plus a real store, which is -// what the history needs. -func assistantHistoryServer(t *testing.T, asst *config.Assistant, httpClient *http.Client) (*Server, *state.Store) { - t.Helper() - st, err := state.Open(t.TempDir() + "/state.db") - if err != nil { - t.Fatalf("open store: %v", err) - } - t.Cleanup(func() { _ = st.Close() }) - ctrl := control.NewState(0, 50, "meter") - tel := telemetry.NewStore() - tel.DriverHealthMut("meter").RecordSuccess() - tel.Update("meter", telemetry.DerMeter, 500, nil, nil) - srv := New(&Deps{ - Ctrl: ctrl, - CtrlMu: &sync.Mutex{}, - Tel: tel, - Cfg: &config.Config{Assistant: asst}, - CfgMu: &sync.RWMutex{}, - State: st, - Version: "test-version", - AssistantHTTP: httpClient, - }) - return srv, st -} - -func TestAssistantThreadsAreProtectedReads(t *testing.T) { - srv, _ := assistantHistoryServer(t, nil, nil) - for _, path := range []string{ - "/api/assistant/threads", - "/api/assistant/threads/aaaa000000000001", - } { - req := httptest.NewRequest(http.MethodGet, path, nil) - if facts := srv.Route(req); facts.Tier != apiauth.TierRead { - t.Fatalf("%s tier = %v, want Read", path, facts.Tier) - } - if !protectedReadPath(path) { - t.Fatalf("%s is not a protected read — it carries answers about this house", path) - } - } -} - -func TestAssistantThreadDeleteNeedsConfigure(t *testing.T) { - srv, _ := assistantHistoryServer(t, nil, nil) - req := httptest.NewRequest(http.MethodDelete, "/api/assistant/threads/aaaa000000000001", nil) - if facts := srv.Route(req); facts.Tier != apiauth.TierConfigure { - t.Fatalf("tier = %v, want Configure", facts.Tier) - } -} - -func TestAssistantThreadsListsNewestFirst(t *testing.T) { - srv, st := assistantHistoryServer(t, nil, nil) - for _, id := range []string{"aaaa000000000001", "aaaa000000000002"} { - if err := st.SaveAssistantThread(state.AssistantThread{ - ID: id, - Turns: []state.AssistantTurn{{Role: "user", Text: "q " + id}, {Role: "assistant", Text: "a"}}, - }); err != nil { - t.Fatal(err) - } - } - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/assistant/threads", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) - } - var got struct { - Threads []state.AssistantThread `json:"threads"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { - t.Fatal(err) - } - if len(got.Threads) != 2 { - t.Fatalf("threads = %d, want 2", len(got.Threads)) - } - if got.Threads[0].ID != "aaaa000000000002" { - t.Fatalf("first = %q, want the newest", got.Threads[0].ID) - } - // The list is an index, not a transcript: turns stay out of it. - if len(got.Threads[0].Turns) != 0 { - t.Fatalf("list carried %d turns; it should only carry the count", len(got.Threads[0].Turns)) - } - if got.Threads[0].TurnCount != 2 { - t.Fatalf("turn count = %d, want 2", got.Threads[0].TurnCount) - } -} - -func TestAssistantThreadReadsBackTheConversation(t *testing.T) { - srv, st := assistantHistoryServer(t, nil, nil) - if err := st.SaveAssistantThread(state.AssistantThread{ - ID: "bbbb000000000001", - Model: "qwen/qwen3-8b:free", - Turns: []state.AssistantTurn{ - {Role: "user", Text: "why is it charging?"}, - {Role: "assistant", Text: "cheap slot"}, - }, - }); err != nil { - t.Fatal(err) - } - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/assistant/threads/bbbb000000000001", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) - } - var got state.AssistantThread - if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { - t.Fatal(err) - } - if len(got.Turns) != 2 || got.Turns[0].Text != "why is it charging?" { - t.Fatalf("turns = %#v", got.Turns) - } - if got.Model != "qwen/qwen3-8b:free" { - t.Fatalf("model = %q", got.Model) - } -} - -func TestAssistantThreadRejectsAStrangeID(t *testing.T) { - srv, _ := assistantHistoryServer(t, nil, nil) - for _, id := range []string{"../../etc/passwd", "short", "ZZZZ000000000001", "aaaa00000000000"} { - rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/assistant/threads/x", nil) - req.SetPathValue("id", id) - srv.handleAssistantThread(rr, req) - if rr.Code != http.StatusBadRequest { - t.Fatalf("id %q: status = %d, want 400", id, rr.Code) - } - } -} - -func TestAssistantThreadMissingIs404(t *testing.T) { - srv, _ := assistantHistoryServer(t, nil, nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/assistant/threads/cccc000000000009", nil)) - if rr.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404", rr.Code) - } -} - -// The whole point: an answer outlives the dialog that produced it. -func TestAssistantAskStoresTheConversation(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "model": "openrouter/free", - "choices": []map[string]any{ - {"message": map[string]string{ - "role": "assistant", - "content": "## Answer\nCheap slot.\n\n## Issue title\n\n## Issue body\n", - }}, - }, - }) - })) - defer upstream.Close() - - srv, st := assistantHistoryServer(t, &config.Assistant{ - Enabled: true, APIKey: "k", BaseURL: upstream.URL, - }, upstream.Client()) - - rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/assistant/ask", - strings.NewReader(`{"question":"why is it charging?"}`)) - req.Header.Set("Content-Type", "application/json") - srv.handleAssistantAsk(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) - } - var first assistantAskResponse - if err := json.Unmarshal(rr.Body.Bytes(), &first); err != nil { - t.Fatal(err) - } - if first.ThreadID == "" { - t.Fatal("no thread id came back; the answer was not stored") - } - stored, ok, err := st.AssistantThreadByID(first.ThreadID) - if err != nil || !ok { - t.Fatalf("thread not stored: ok=%v err=%v", ok, err) - } - if len(stored.Turns) != 2 || stored.Turns[0].Text != "why is it charging?" { - t.Fatalf("turns = %#v", stored.Turns) - } - if stored.Turns[1].Text != "Cheap slot." { - t.Fatalf("stored answer = %q", stored.Turns[1].Text) - } - - // A follow-up naming that thread grows the same row. - rr2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodPost, "/api/assistant/ask", - strings.NewReader(`{"question":"and tonight?","thread_id":`+jsonString(first.ThreadID)+`}`)) - req2.Header.Set("Content-Type", "application/json") - srv.handleAssistantAsk(rr2, req2) - if rr2.Code != http.StatusOK { - t.Fatalf("follow-up status = %d: %s", rr2.Code, rr2.Body.String()) - } - var second assistantAskResponse - if err := json.Unmarshal(rr2.Body.Bytes(), &second); err != nil { - t.Fatal(err) - } - if second.ThreadID != first.ThreadID { - t.Fatalf("follow-up thread = %q, want %q", second.ThreadID, first.ThreadID) - } - grown, _, err := st.AssistantThreadByID(first.ThreadID) - if err != nil { - t.Fatal(err) - } - if len(grown.Turns) != 4 { - t.Fatalf("turns = %d after a follow-up, want 4", len(grown.Turns)) - } - list, err := st.RecentAssistantThreads(0) - if err != nil { - t.Fatal(err) - } - if len(list) != 1 { - t.Fatalf("threads = %d, want the follow-up to stay in one row", len(list)) - } -} - -// A client-supplied id must not be able to name a row of its own choosing. -func TestAssistantAskIgnoresAForgedThreadID(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "## Answer\nFine.\n"}}, - }, - }) - })) - defer upstream.Close() - - srv, st := assistantHistoryServer(t, &config.Assistant{ - Enabled: true, APIKey: "k", BaseURL: upstream.URL, - }, upstream.Client()) - - rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/assistant/ask", - strings.NewReader(`{"question":"q","thread_id":"../../etc/passwd"}`)) - req.Header.Set("Content-Type", "application/json") - srv.handleAssistantAsk(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) - } - var got assistantAskResponse - if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { - t.Fatal(err) - } - if !validAssistantThreadID(got.ThreadID) { - t.Fatalf("thread id = %q, want a fresh generated one", got.ThreadID) - } - if _, ok, _ := st.AssistantThreadByID("../../etc/passwd"); ok { - t.Fatal("the forged id named a row") - } -} - -func TestAssistantThreadsClearEmptiesHistory(t *testing.T) { - srv, st := assistantHistoryServer(t, nil, nil) - if err := st.SaveAssistantThread(state.AssistantThread{ - ID: "dddd000000000001", - Turns: []state.AssistantTurn{{Role: "user", Text: "q"}, {Role: "assistant", Text: "a"}}, - }); err != nil { - t.Fatal(err) - } - rr := httptest.NewRecorder() - srv.handleAssistantThreadsClear(rr, httptest.NewRequest(http.MethodDelete, "/api/assistant/threads", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("status = %d", rr.Code) - } - list, err := st.RecentAssistantThreads(0) - if err != nil { - t.Fatal(err) - } - if len(list) != 0 { - t.Fatalf("threads = %d after clear, want 0", len(list)) - } -} - -// A box with no store still answers; it just cannot remember. -func TestAssistantHistoryWithoutAStoreIsEmptyNotAnError(t *testing.T) { - srv := assistantTestServer(t, nil, nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/assistant/threads", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("status = %d, want 200 with an empty list", rr.Code) - } - if !strings.Contains(rr.Body.String(), `"threads"`) { - t.Fatalf("body = %s", rr.Body.String()) - } -} diff --git a/go/internal/api/api_assistant_tools.go b/go/internal/api/api_assistant_tools.go deleted file mode 100644 index 743e2fb09..000000000 --- a/go/internal/api/api_assistant_tools.go +++ /dev/null @@ -1,264 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strings" - "time" - - "github.com/srcfl/ftw/go/internal/assistant" - "github.com/srcfl/ftw/go/internal/control" - "github.com/srcfl/ftw/go/internal/mpc" - "github.com/srcfl/ftw/go/internal/telemetry" -) - -func (s *Server) runAssistantTool(name string, args json.RawMessage) (string, error) { - switch name { - case assistant.ToolSupportReport: - if s.deps.Ctrl == nil || s.deps.Tel == nil { - return "", fmt.Errorf("site state is not available") - } - return s.buildSupportReport(context.Background(), time.Now()), nil - case assistant.ToolDriverHealth: - return s.toolDriverHealth(args), nil - case assistant.ToolRecentLogs: - return s.toolRecentLogs(args), nil - case assistant.ToolPlanNow: - return s.toolPlanNow(), nil - case assistant.ToolVersion: - v := strings.TrimSpace(s.deps.Version) - if v == "" { - v = "dev" - } - return "FTW version " + v, nil - default: - return "unknown tool; Ask why is read-only", nil - } -} - -func (s *Server) toolDriverHealth(args json.RawMessage) string { - if s.deps.Tel == nil { - return "no telemetry" - } - var in struct { - Name string `json:"name"` - } - _ = json.Unmarshal(args, &in) - want := strings.TrimSpace(in.Name) - health := s.deps.Tel.AllHealth() - names := make([]string, 0, len(health)) - for name := range health { - if want != "" && name != want { - continue - } - names = append(names, name) - } - sort.Strings(names) - if want != "" && len(names) == 0 { - return "no driver named " + want - } - if len(names) == 0 { - return "no drivers" - } - now := time.Now() - var b strings.Builder - for _, name := range names { - h := health[name] - status := h.Status.String() - if h.DeviceFault { - status = "fault" - } - fmt.Fprintf(&b, "%s status=%s errors=%d ticks=%d", name, status, h.ConsecutiveErrors, h.TickCount) - if h.LastSuccess != nil { - fmt.Fprintf(&b, " last_success=%s ago", fmtReportAge(now.Sub(*h.LastSuccess))) - } else { - b.WriteString(" last_success=never") - } - if h.DeviceFault && h.DeviceFaultReason != "" { - fmt.Fprintf(&b, " fault=%q", assistant.Redact(h.DeviceFaultReason)) - } - if h.LastError != "" { - fmt.Fprintf(&b, " last_error=%q", assistant.Redact(h.LastError)) - } - b.WriteByte('\n') - } - return strings.TrimSpace(b.String()) -} - -func (s *Server) toolRecentLogs(args json.RawMessage) string { - if s.deps.LogRing == nil { - return "log ring not configured" - } - var in struct { - Driver string `json:"driver"` - Limit int `json:"limit"` - } - _ = json.Unmarshal(args, &in) - limit := in.Limit - if limit <= 0 { - limit = 30 - } - if limit > 80 { - limit = 80 - } - var entries []telemetry.LogEntry - if strings.TrimSpace(in.Driver) != "" { - entries = s.deps.LogRing.RecentByDriver(in.Driver, 200) - } else { - entries = s.deps.LogRing.RecentGlobal(400) - } - kept := make([]telemetry.LogEntry, 0, limit) - for i := len(entries) - 1; i >= 0 && len(kept) < limit; i-- { - lvl := strings.ToUpper(entries[i].Level) - if lvl != "WARN" && lvl != "WARNING" && lvl != "ERROR" { - continue - } - kept = append(kept, entries[i]) - } - if len(kept) == 0 { - return "no recent warnings or errors" - } - var b strings.Builder - for i := len(kept) - 1; i >= 0; i-- { - e := kept[i] - fmt.Fprintf(&b, "%s %s", e.TS.Format("15:04:05"), e.Level) - if e.Driver != "" { - fmt.Fprintf(&b, " [%s]", e.Driver) - } - b.WriteByte(' ') - b.WriteString(assistant.Redact(e.Msg)) - if e.Attrs != "" { - b.WriteByte(' ') - b.WriteString(assistant.Redact(e.Attrs)) - } - b.WriteByte('\n') - } - return strings.TrimSpace(b.String()) -} - -func (s *Server) toolPlanNow() string { - if s.deps.Ctrl == nil || s.deps.Tel == nil { - return "site state is not available" - } - now := time.Now() - s.deps.CtrlMu.Lock() - ctrl := *s.deps.Ctrl - targets := append([]control.DispatchTarget{}, s.deps.Ctrl.LastTargets...) - slotEnergy := s.deps.Ctrl.SlotEnergy() - s.deps.CtrlMu.Unlock() - snap := s.liveNow(ctrl, now) - var plan *mpc.Plan - var replanAt time.Time - var replanReason string - var activeSlot *mpc.Action - if s.deps.MPC != nil { - plan = s.deps.MPC.Latest() - replanAt, replanReason = s.deps.MPC.LastReplanInfo() - activeSlot = activeAction(plan, now) - } - var b strings.Builder - writeRightNow(&b, ctrl, snap, activeSlot, targets, slotEnergy, now) - b.WriteString("\n") - writePlanSection(&b, plan, replanAt, replanReason, now) - writePlanAhead(&b, plan, now) - return strings.TrimSpace(b.String()) -} - -func batteryIntent(w float64) string { - if w > 50 { - return "charge" - } - if w < -50 { - return "discharge" - } - return "idle" -} - -// writePlanAhead groups the next 24 hours so Ask why can explain the -// schedule without dumping every 15-minute slot. -func writePlanAhead(b *strings.Builder, plan *mpc.Plan, now time.Time) { - if plan == nil || len(plan.Actions) == 0 { - return - } - b.WriteString("## Hours ahead\n\n") - b.WriteString("Grouped battery intent for the next 24 hours. Positive W is charge. Negative W is discharge.\n\n") - - limit := now.Add(24 * time.Hour) - var ( - started bool - dir, reason string - start, end time.Time - sumW float64 - n, blocks int - minPrice, maxPrice float64 - endSoC float64 - ) - flush := func() { - if !started || n == 0 { - return - } - fmt.Fprintf(b, "%s–%s %s %s · %s", - start.Format("15:04"), end.Format("15:04"), - dir, fmtReportW(sumW/float64(n)), reason) - if minPrice == maxPrice { - fmt.Fprintf(b, " · price %.0f öre", minPrice) - } else { - fmt.Fprintf(b, " · price %.0f–%.0f öre", minPrice, maxPrice) - } - fmt.Fprintf(b, " · charge ends %.0f%%\n", endSoC) - blocks++ - started = false - } - for i := range plan.Actions { - a := &plan.Actions[i] - slotStart := time.UnixMilli(a.SlotStartMs) - slotLen := time.Duration(a.SlotLenMin) * time.Minute - if slotLen <= 0 { - slotLen = 15 * time.Minute - } - slotEnd := slotStart.Add(slotLen) - if slotEnd.Before(now) { - continue - } - if !slotStart.Before(limit) { - break - } - d := batteryIntent(a.BatteryW) - r := strings.TrimSpace(a.Reason) - if started && (d != dir || r != reason) { - flush() - if blocks >= 16 { - b.WriteString("… further slots omitted\n") - break - } - } - if !started { - dir, reason = d, r - start = slotStart - if start.Before(now) { - start = now - } - minPrice, maxPrice = a.PriceOre, a.PriceOre - started = true - n = 0 - sumW = 0 - } - if a.PriceOre < minPrice { - minPrice = a.PriceOre - } - if a.PriceOre > maxPrice { - maxPrice = a.PriceOre - } - sumW += a.BatteryW - n++ - end = slotEnd - endSoC = a.SoC * 100 - } - flush() - if blocks == 0 { - b.WriteString("No upcoming slots in the next 24 hours.\n") - } - b.WriteByte('\n') -} diff --git a/go/internal/api/api_config_apply_test.go b/go/internal/api/api_config_apply_test.go index 34b4d2efc..4819f5d87 100644 --- a/go/internal/api/api_config_apply_test.go +++ b/go/internal/api/api_config_apply_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "net/http" "net/http/httptest" "path/filepath" @@ -77,7 +78,9 @@ func TestPostConfigFirstSiteMeterReachesControl(t *testing.T) { } } -func TestPostConfigKeepsAskWhyOffWhenKeyPasted(t *testing.T) { +// A Settings page loaded before Ask why was removed still posts its block. +// The save succeeds and keeps none of it. +func TestPostConfigDropsRetiredAskWhySettings(t *testing.T) { srv, _, cfg := postConfigServer(t, nil) body := `{ "site": {"name": "Test", "smoothing_alpha": 0.3}, @@ -94,14 +97,12 @@ func TestPostConfigKeepsAskWhyOffWhenKeyPasted(t *testing.T) { if code := postConfig(t, srv, body); code != 200 { t.Fatalf("POST /api/config = %d, want 200", code) } - if cfg.Assistant == nil { - t.Fatal("assistant missing after save") + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) } - if cfg.Assistant.Enabled { - t.Fatal("pasting a key must not force Ask why on when Enable is off") - } - if cfg.Assistant.APIKey != "sk-or-v1-new" { - t.Fatalf("api_key = %q", cfg.Assistant.APIKey) + if strings.Contains(string(raw), "sk-or-v1-new") { + t.Fatalf("saved settings still carry the Ask why key: %s", raw) } } diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index 9b593461f..aff4dd444 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -23,7 +23,6 @@ import ( "strings" "time" - "github.com/srcfl/ftw/go/internal/assistant" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/telemetry" @@ -537,8 +536,8 @@ func (s *Server) handleGlobalLogs(w http.ResponseWriter, r *http.Request) { // GET /api/support/dump — zip archive with everything a developer needs // to triage a support incident: redacted config, full driver health JSON, -// recent global + per-driver logs (secrets stripped at least as strictly -// as Ask why), last 1 h of TS samples per (driver, metric), and a +// recent global + per-driver logs (secrets and IPv4 addresses stripped), +// last 1 h of TS samples per (driver, metric), and a // manifest. SQLite is NOT included; the dump is intended to be small // enough to attach to a chat message — measured at ~6 kB on a two-driver // install. @@ -774,19 +773,22 @@ func hasAuthSegment(k string) bool { } var ( + dumpIPv4Re = regexp.MustCompile(`\b\d{1,3}(?:\.\d{1,3}){3}\b`) + dumpBearerRe = regexp.MustCompile(`(?i)bearer\s+\S+`) dumpJSONSecretRe = regexp.MustCompile(`(?i)("([^"\\]+)"\s*:\s*)("(?:\\.|[^"\\])*")`) dumpFormSecretRe = regexp.MustCompile(`(?i)\b([A-Za-z][A-Za-z0-9_-]*)=([^\s&"]+)`) dumpBasicAuthRe = regexp.MustCompile(`(?i)basic\s+[A-Za-z0-9+/=_-]+`) ) -// redactDumpLog applies Ask-why redaction, then also blanks JSON/form -// fields whose keys isSensitiveKey would catch. Stricter on OAuth bodies -// that Lua logs as `HTTP %d: %s`. +// redactDumpLog removes IPv4 addresses and credentials, including JSON and +// form fields whose keys isSensitiveKey would catch. Stricter on OAuth +// bodies that Lua logs as `HTTP %d: %s`. func redactDumpLog(s string) string { if s == "" { return s } - s = assistant.Redact(s) + s = dumpIPv4Re.ReplaceAllString(s, "[ip omitted]") + s = dumpBearerRe.ReplaceAllString(s, "Bearer [omitted]") s = dumpBasicAuthRe.ReplaceAllString(s, "Basic [omitted]") s = dumpJSONSecretRe.ReplaceAllStringFunc(s, func(m string) string { parts := dumpJSONSecretRe.FindStringSubmatch(m) diff --git a/go/internal/api/api_drivers_debug_test.go b/go/internal/api/api_drivers_debug_test.go index 4417a56c2..45e17c3af 100644 --- a/go/internal/api/api_drivers_debug_test.go +++ b/go/internal/api/api_drivers_debug_test.go @@ -289,9 +289,9 @@ func TestIsSensitiveKey(t *testing.T) { } func TestRedactDumpLog(t *testing.T) { - in := `HTTP 400: {"refresh_token":"RT-secret-value","access_token":"AT-secret-value"} Bearer eyJabc.def password=hunter2 poll ok` + in := `HTTP 400: {"refresh_token":"RT-secret-value","access_token":"AT-secret-value"} Bearer eyJabc.def password=hunter2 dial 192.168.1.153:502 poll ok` got := redactDumpLog(in) - for _, leak := range []string{"RT-secret-value", "AT-secret-value", "eyJabc.def", "hunter2"} { + for _, leak := range []string{"RT-secret-value", "AT-secret-value", "eyJabc.def", "hunter2", "192.168.1.153"} { if strings.Contains(got, leak) { t.Errorf("redactDumpLog leaked %q in %q", leak, got) } diff --git a/go/internal/api/security.go b/go/internal/api/security.go index 5ece51c7d..064dc5781 100644 --- a/go/internal/api/security.go +++ b/go/internal/api/security.go @@ -241,7 +241,6 @@ func protectedReadPath(path string) bool { case "/api/config", "/api/support/dump", "/api/support/report", - "/api/assistant/status", "/api/logs", "/api/system/info", "/api/storage/inventory", @@ -273,11 +272,6 @@ func protectedReadPath(path string) bool { if path == "/api/backups" || strings.HasPrefix(path, "/api/backups/") { return true } - // Ask why history holds the model's answers about this house, so the - // list and every single conversation stay off a public host. - if path == "/api/assistant/threads" || strings.HasPrefix(path, "/api/assistant/threads/") { - return true - } if path == "/api/series" || strings.HasPrefix(path, "/api/series/") { return true } diff --git a/go/internal/api/security_test.go b/go/internal/api/security_test.go index 82c746631..70d8b0bd5 100644 --- a/go/internal/api/security_test.go +++ b/go/internal/api/security_test.go @@ -107,12 +107,6 @@ func TestAuthenticateGuardsProtectedReads(t *testing.T) { {name: "support dump via HEAD", method: http.MethodHead, path: "/api/support/dump"}, {name: "support report", method: http.MethodGet, path: "/api/support/report"}, {name: "support report via HEAD", method: http.MethodHead, path: "/api/support/report"}, - {name: "assistant status", method: http.MethodGet, path: "/api/assistant/status"}, - {name: "assistant status via HEAD", method: http.MethodHead, path: "/api/assistant/status"}, - {name: "Ask why history", method: http.MethodGet, path: "/api/assistant/threads"}, - {name: "Ask why history via HEAD", method: http.MethodHead, path: "/api/assistant/threads"}, - {name: "one Ask why conversation", method: http.MethodGet, path: "/api/assistant/threads/aaaa000000000001"}, - {name: "one Ask why conversation via HEAD", method: http.MethodHead, path: "/api/assistant/threads/aaaa000000000001"}, {name: "logs", method: http.MethodGet, path: "/api/logs"}, {name: "logs via HEAD", method: http.MethodHead, path: "/api/logs"}, {name: "system info", method: http.MethodGet, path: "/api/system/info"}, @@ -238,9 +232,6 @@ func TestAuthenticateRequiresRemoteTokenForProtectedReads(t *testing.T) { "/api/config", "/api/support/dump", "/api/support/report", - "/api/assistant/status", - "/api/assistant/threads", - "/api/assistant/threads/aaaa000000000001", "/api/logs", "/api/system/info", "/api/storage/inventory", diff --git a/go/internal/assistant/assistant.go b/go/internal/assistant/assistant.go deleted file mode 100644 index 8cc6e08cd..000000000 --- a/go/internal/assistant/assistant.go +++ /dev/null @@ -1,539 +0,0 @@ -// Package assistant calls an OpenAI-compatible chat API (OpenRouter by -// default) to explain a local FTW site. It never issues commands. -package assistant - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/url" - "regexp" - "strings" - "time" - "unicode/utf8" -) - -const ( - DefaultModel = "openrouter/free" - DefaultBaseURL = "https://openrouter.ai/api/v1" - // Timeout covers a slow free-tier completion, including a few tool rounds. - Timeout = 90 * time.Second - // maxReportRunes keeps a stuffed one-shot inside a free-model context. - maxReportRunes = 80_000 - maxQuestion = 2000 - maxTokens = 2500 - maxIssueTitle = 80 - maxHistoryTurns = 6 - maxHistoryRunes = 1500 -) - -// Request is one Ask why turn. -type Request struct { - APIKey string - Model string - BaseURL string - Question string - // Trigger is a short fact from the UI, e.g. "driver sungrow is offline". - Trigger string - // Report is only used when Run is nil (tests, or a model without tools). - Report string - // Snapshot is a local facts pack gathered before the model runs. - Snapshot string - // History is earlier turns in this dialog, oldest first. Follow-ups - // need it; the current question is not included. - History []Turn - // Run executes read-only tools. Nil means no tool loop: the report is - // stuffed into the first user message, matching the original one-shot. - Run Runner - // Progress is optional live status for the UI. kind is "status", - // "tool", "delta" (a token of the answer), or "round" (a new model - // round starts, so deltas so far were not the answer). - Progress func(kind, text string) -} - -// Reply is what the UI shows. Issue fields are empty when the model does -// not think this is a bug. -type Reply struct { - Answer string - IssueTitle string - IssueBody string - Model string - ResolvedModel string - ToolRounds int -} - -// APIError is an outbound failure the HTTP layer can map to a status code. -type APIError struct { - Status int - Msg string -} - -func (e *APIError) Error() string { return e.Msg } - -// Turn is one earlier Ask why line in a dialog. -type Turn struct { - Role string `json:"role"` - Text string `json:"text"` -} - -// SanitizeHistory keeps user/assistant lines, caps count and length. -func SanitizeHistory(in []Turn) []Turn { - out := make([]Turn, 0, maxHistoryTurns) - for _, t := range in { - if len(out) >= maxHistoryTurns { - break - } - role := strings.ToLower(strings.TrimSpace(t.Role)) - if role != "user" && role != "assistant" { - continue - } - text := strings.TrimSpace(t.Text) - if text == "" { - continue - } - if utf8.RuneCountInString(text) > maxHistoryRunes { - text = string([]rune(text)[:maxHistoryRunes]) + "…" - } - out = append(out, Turn{Role: role, Text: text}) - } - return out -} - -// Client posts chat completions. HTTP may be nil (a 90s client is used). -type Client struct { - HTTP *http.Client -} - -type chatRequest struct { - Model string `json:"model"` - Messages []chatMessage `json:"messages"` - MaxTokens int `json:"max_tokens"` - Temperature float64 `json:"temperature"` - Stream bool `json:"stream,omitempty"` - Tools []ToolDef `json:"tools,omitempty"` - ToolChoice string `json:"tool_choice,omitempty"` -} - -type chatMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolCalls []toolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - -type toolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` -} - -type chatResponse struct { - Model string `json:"model"` - Choices []struct { - Message chatMessage `json:"message"` - } `json:"choices"` - Error *struct { - Message string `json:"message"` - } `json:"error"` -} - -// Complete asks the model to explain the site. With Run set it loops on -// read-only tools. Without Run it stuffs Report into the first message. -func (c *Client) Complete(ctx context.Context, req Request) (Reply, error) { - var zero Reply - key := strings.TrimSpace(req.APIKey) - if key == "" { - return zero, &APIError{Status: http.StatusConflict, Msg: "Ask why needs an API key in Settings → System"} - } - model := strings.TrimSpace(req.Model) - if model == "" { - model = DefaultModel - } - base := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") - if base == "" { - base = DefaultBaseURL - } - question := strings.TrimSpace(req.Question) - if question == "" { - question = "What is going on right now?" - } - if utf8.RuneCountInString(question) > maxQuestion { - return zero, &APIError{Status: http.StatusBadRequest, Msg: "question is too long"} - } - - user := "Question:\n" + question - if t := strings.TrimSpace(req.Trigger); t != "" { - user += "\n\nTrigger:\n" + t - } - if snap := strings.TrimSpace(req.Snapshot); snap != "" { - user += "\n\nSite snapshot:\n\n" + snap - } - var tools []ToolDef - if req.Run != nil { - tools = ToolDefs() - user += "\n\nUse tools only if the snapshot is not enough. Finish with ## Answer, ## Issue title, and ## Issue body." - } else { - report := strings.TrimSpace(req.Report) - if report == "" { - return zero, &APIError{Status: http.StatusServiceUnavailable, Msg: "help report is empty"} - } - if utf8.RuneCountInString(report) > maxReportRunes { - runes := []rune(report) - report = string(runes[:maxReportRunes]) + "\n\n[truncated]\n" - } - user += "\n\nSite report:\n\n" + report - } - - messages := []chatMessage{ - {Role: "system", Content: Skill}, - } - for _, t := range SanitizeHistory(req.History) { - messages = append(messages, chatMessage{Role: t.Role, Content: t.Text}) - } - messages = append(messages, chatMessage{Role: "user", Content: user}) - - httpClient := c.HTTP - if httpClient == nil { - httpClient = &http.Client{Timeout: Timeout} - } - endpoint, err := url.JoinPath(base, "chat", "completions") - if err != nil { - return zero, &APIError{Status: http.StatusBadRequest, Msg: "assistant.base_url is not a valid URL"} - } - - toolRounds := 0 - for round := 0; round < maxRounds; round++ { - useTools := len(tools) > 0 && round < maxRounds-1 - wire := chatRequest{ - Model: model, - Messages: messages, - MaxTokens: maxTokens, - Temperature: 0.2, - Stream: true, - } - if useTools { - wire.Tools = tools - wire.ToolChoice = "auto" - } - if req.Progress != nil { - // A round starts its own answer. Text streamed before a tool - // call belongs to that round, not to the reply the operator - // ends up reading, so the UI drops what it has so far. - req.Progress("round", "") - req.Progress("status", "Asking the model") - } - msg, resolved, err := c.post(ctx, httpClient, endpoint, key, wire, req.Progress) - if err != nil { - return zero, err - } - if len(msg.ToolCalls) == 0 || req.Run == nil || !useTools { - reply := Parse(msg.Content) - reply.Model = model - reply.ResolvedModel = resolved - if reply.ResolvedModel == "" { - reply.ResolvedModel = model - } - reply.ToolRounds = toolRounds - return reply, nil - } - messages = append(messages, msg) - for _, call := range msg.ToolCalls { - toolRounds++ - if req.Progress != nil { - req.Progress("tool", strings.TrimSpace(call.Function.Name)) - } - messages = append(messages, chatMessage{ - Role: "tool", - ToolCallID: call.ID, - Content: runTool(req.Run, call), - }) - } - } - return zero, &APIError{Status: http.StatusBadGateway, Msg: "model kept calling tools without answering"} -} - -func runTool(run Runner, call toolCall) string { - name := strings.TrimSpace(call.Function.Name) - if !AllowedTool(name) { - return "unknown tool; Ask why is read-only" - } - args := json.RawMessage(strings.TrimSpace(call.Function.Arguments)) - if len(args) == 0 { - args = json.RawMessage(`{}`) - } - out, err := run(name, args) - if err != nil { - return "tool error: " + err.Error() - } - out = Redact(strings.TrimSpace(out)) - if utf8.RuneCountInString(out) > maxToolResult { - out = string([]rune(out)[:maxToolResult]) + "\n[truncated]" - } - if out == "" { - return "(empty)" - } - return out -} - -func (c *Client) post(ctx context.Context, httpClient *http.Client, endpoint, key string, wire chatRequest, progress func(kind, text string)) (chatMessage, string, error) { - var zero chatMessage - body, err := json.Marshal(wire) - if err != nil { - return zero, "", err - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return zero, "", err - } - httpReq.Header.Set("Authorization", "Bearer "+key) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "text/event-stream") - httpReq.Header.Set("HTTP-Referer", "https://github.com/srcfl/ftw") - httpReq.Header.Set("X-Title", "FTW") - - resp, err := httpClient.Do(httpReq) - if err != nil { - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "could not reach the model API"} - } - defer resp.Body.Close() - - switch { - case resp.StatusCode == http.StatusUnauthorized: - return zero, "", &APIError{Status: http.StatusUnauthorized, Msg: "OpenRouter rejected the API key"} - case resp.StatusCode == http.StatusPaymentRequired: - return zero, "", &APIError{Status: http.StatusPaymentRequired, Msg: "this model needs credits; use openrouter/free or add credit"} - case resp.StatusCode == http.StatusTooManyRequests: - return zero, "", &APIError{Status: http.StatusTooManyRequests, Msg: "model rate limit; try again in a minute"} - case resp.StatusCode >= 500: - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "model API is unavailable"} - case resp.StatusCode >= 400: - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "model API error"} - } - - if strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") { - return readStream(resp.Body, progress) - } - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - msg, model, err := parseChatJSON(raw) - if err != nil { - return zero, "", err - } - if progress != nil && msg.Content != "" && len(msg.ToolCalls) == 0 { - progress("delta", msg.Content) - } - return msg, model, nil -} - -type streamChunk struct { - Model string `json:"model"` - Choices []struct { - Delta struct { - Content string `json:"content"` - ToolCalls []streamToolCall `json:"tool_calls"` - } `json:"delta"` - } `json:"choices"` - Error *struct { - Message string `json:"message"` - } `json:"error"` -} - -type streamToolCall struct { - Index int `json:"index"` - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` -} - -type toolAcc struct { - id, typ, name, args string -} - -func readStream(body io.Reader, progress func(kind, text string)) (chatMessage, string, error) { - var zero chatMessage - sc := bufio.NewScanner(body) - sc.Buffer(make([]byte, 0, 64*1024), 1<<20) - var content strings.Builder - resolved := "" - accs := map[int]*toolAcc{} - var order []int - for sc.Scan() { - line := sc.Text() - if line == "" || strings.HasPrefix(line, ":") { - continue - } - if !strings.HasPrefix(line, "data:") { - continue - } - payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if payload == "[DONE]" { - break - } - var chunk streamChunk - if err := json.Unmarshal([]byte(payload), &chunk); err != nil { - continue - } - if chunk.Error != nil && chunk.Error.Message != "" { - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "model API error"} - } - if chunk.Model != "" { - resolved = strings.TrimSpace(chunk.Model) - } - if len(chunk.Choices) == 0 { - continue - } - d := chunk.Choices[0].Delta - if d.Content != "" { - content.WriteString(d.Content) - if progress != nil { - progress("delta", d.Content) - } - } - for _, tc := range d.ToolCalls { - a, ok := accs[tc.Index] - if !ok { - a = &toolAcc{typ: "function"} - accs[tc.Index] = a - order = append(order, tc.Index) - } - if tc.ID != "" { - a.id = tc.ID - } - if tc.Type != "" { - a.typ = tc.Type - } - if tc.Function.Name != "" { - a.name = tc.Function.Name - } - a.args += tc.Function.Arguments - } - } - if err := sc.Err(); err != nil { - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "model API stream failed"} - } - msg := chatMessage{Role: "assistant", Content: content.String()} - for _, idx := range order { - a := accs[idx] - tc := toolCall{ID: a.id, Type: a.typ} - tc.Function.Name = a.name - tc.Function.Arguments = a.args - msg.ToolCalls = append(msg.ToolCalls, tc) - } - if msg.Content == "" && len(msg.ToolCalls) == 0 { - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "model returned no answer"} - } - return msg, resolved, nil -} - -func parseChatJSON(raw []byte) (chatMessage, string, error) { - var zero chatMessage - var parsed chatResponse - if err := json.Unmarshal(raw, &parsed); err != nil { - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "model API returned unreadable JSON"} - } - if parsed.Error != nil && parsed.Error.Message != "" { - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "model API error"} - } - if len(parsed.Choices) == 0 { - return zero, "", &APIError{Status: http.StatusBadGateway, Msg: "model returned no answer"} - } - return parsed.Choices[0].Message, strings.TrimSpace(parsed.Model), nil -} - -// Parse splits the model's markdown into answer and optional issue fields. -func Parse(content string) Reply { - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.TrimSpace(content) - sections := splitSections(content) - answer := strings.TrimSpace(sections["answer"]) - if answer == "" { - answer = content - } - title := cleanIssueTitle(sections["issue title"]) - body := Redact(strings.TrimSpace(sections["issue body"])) - if title == "" { - body = "" - } - return Reply{Answer: answer, IssueTitle: title, IssueBody: body} -} - -func splitSections(content string) map[string]string { - out := map[string]string{} - lines := strings.Split(content, "\n") - var current string - var buf []string - flush := func() { - if current == "" { - return - } - out[current] = strings.TrimSpace(strings.Join(buf, "\n")) - } - for _, line := range lines { - if name, ok := headingName(line); ok { - flush() - current = name - buf = buf[:0] - continue - } - buf = append(buf, line) - } - flush() - return out -} - -func headingName(line string) (string, bool) { - s := strings.TrimSpace(line) - switch { - case strings.HasPrefix(s, "#"): - s = strings.TrimSpace(strings.TrimLeft(s, "#")) - case strings.HasPrefix(s, "**") && strings.HasSuffix(s, "**"): - s = strings.TrimSpace(strings.Trim(s, "*")) - default: - return "", false - } - if s == "" { - return "", false - } - switch strings.ToLower(s) { - case "answer", "issue title", "issue body": - return strings.ToLower(s), true - default: - return "", false - } -} - -func cleanIssueTitle(s string) string { - s = strings.TrimSpace(s) - s = strings.Trim(s, "`\"'") - s = strings.TrimSpace(s) - switch strings.ToLower(s) { - case "", "none", "n/a", "na", "-", "empty", "not a bug": - return "" - } - if utf8.RuneCountInString(s) > maxIssueTitle { - s = string([]rune(s)[:maxIssueTitle-1]) + "…" - } - return s -} - -var ( - ipv4Re = regexp.MustCompile(`\b\d{1,3}(?:\.\d{1,3}){3}\b`) - openKey = regexp.MustCompile(`sk-or-v1-\S+`) - bearerRe = regexp.MustCompile(`(?i)bearer\s+\S+`) -) - -// Redact strips the obvious secret and address patterns from issue text. -func Redact(s string) string { - s = ipv4Re.ReplaceAllString(s, "[ip omitted]") - s = openKey.ReplaceAllString(s, "[key omitted]") - s = bearerRe.ReplaceAllString(s, "Bearer [omitted]") - return s -} diff --git a/go/internal/assistant/assistant_test.go b/go/internal/assistant/assistant_test.go deleted file mode 100644 index 43ad2c8dc..000000000 --- a/go/internal/assistant/assistant_test.go +++ /dev/null @@ -1,408 +0,0 @@ -package assistant - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "unicode/utf8" -) - -func TestParseSplitsAnswerAndIssue(t *testing.T) { - got := Parse("## Answer\nBattery is idle because the plan is in a cheap-hour wait.\n\n## Issue title\n\n## Issue body\n") - if !strings.Contains(got.Answer, "Battery is idle") { - t.Fatalf("answer = %q", got.Answer) - } - if got.IssueTitle != "" || got.IssueBody != "" { - t.Fatalf("expected no issue, got %+v", got) - } -} - -func TestParseKeepsIssueWhenPresent(t *testing.T) { - got := Parse("## Answer\nDriver is offline.\n## Issue title\n[bug] sungrow poll timeout\n## Issue body\nThe Sungrow driver has been offline for 20 minutes.\n") - if got.IssueTitle != "[bug] sungrow poll timeout" { - t.Fatalf("title = %q", got.IssueTitle) - } - if !strings.Contains(got.IssueBody, "Sungrow") { - t.Fatalf("body = %q", got.IssueBody) - } -} - -func TestParseBareProseIsTheAnswer(t *testing.T) { - got := Parse("The house is importing 400 W and that matches the plan.") - if got.Answer != "The house is importing 400 W and that matches the plan." { - t.Fatalf("answer = %q", got.Answer) - } - if got.IssueTitle != "" { - t.Fatalf("unexpected title %q", got.IssueTitle) - } -} - -func TestRedactStripsAddressesAndKeys(t *testing.T) { - in := "Talks to 192.168.1.10 with sk-or-v1-secret and Bearer abcdef" - got := Redact(in) - if strings.Contains(got, "192.168.1.10") || strings.Contains(got, "sk-or-v1") || strings.Contains(got, "abcdef") { - t.Fatalf("not redacted: %q", got) - } -} - -func TestCompletePostsChatCompletions(t *testing.T) { - var gotAuth, gotBody string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/chat/completions" { - t.Errorf("path = %s", r.URL.Path) - } - gotAuth = r.Header.Get("Authorization") - raw, _ := io.ReadAll(r.Body) - gotBody = string(raw) - _ = json.NewEncoder(w).Encode(map[string]any{ - "model": "openrouter/free", - "choices": []map[string]any{ - {"message": map[string]string{ - "role": "assistant", - "content": "## Answer\nIdle as planned.\n\n## Issue title\n\n## Issue body\n", - }}, - }, - }) - })) - defer srv.Close() - - cli := &Client{HTTP: srv.Client()} - reply, err := cli.Complete(context.Background(), Request{ - APIKey: "sk-or-v1-test", - Model: DefaultModel, - BaseURL: srv.URL, - Question: "why idle?", - Report: "# FTW help report\nIdle.", - }) - if err != nil { - t.Fatal(err) - } - if gotAuth != "Bearer sk-or-v1-test" { - t.Fatalf("Authorization = %q", gotAuth) - } - if !strings.Contains(gotBody, "why idle?") || !strings.Contains(gotBody, "FTW help report") { - t.Fatalf("prompt missing question or report: %s", gotBody) - } - if !strings.Contains(gotBody, `"role":"system"`) { - t.Fatalf("system skill missing from %s", gotBody) - } - if !strings.Contains(gotBody, `"stream":true`) { - t.Fatalf("stream not requested: %s", gotBody) - } - if reply.Answer != "Idle as planned." { - t.Fatalf("answer = %q", reply.Answer) - } - if strings.Contains(reply.Answer, "sk-or-v1-test") { - t.Fatal("API key leaked into the reply") - } -} - -func TestCompleteStreamsTokens(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - fl := w.(http.Flusher) - write := func(content string) { - b, _ := json.Marshal(map[string]any{ - "model": "openrouter/free", - "choices": []any{map[string]any{ - "delta": map[string]any{"content": content}, - }}, - }) - fmt.Fprintf(w, "data: %s\n\n", b) - fl.Flush() - } - write("## Answer\n") - write("Idle as planned.") - fmt.Fprintf(w, "data: [DONE]\n\n") - fl.Flush() - })) - defer srv.Close() - - var deltas []string - cli := &Client{HTTP: srv.Client()} - reply, err := cli.Complete(context.Background(), Request{ - APIKey: "k", - BaseURL: srv.URL, - Question: "why idle?", - Report: "idle", - Progress: func(kind, text string) { - if kind == "delta" { - deltas = append(deltas, text) - } - }, - }) - if err != nil { - t.Fatal(err) - } - if strings.Join(deltas, "") != "## Answer\nIdle as planned." { - t.Fatalf("deltas = %#v", deltas) - } - if reply.Answer != "Idle as planned." { - t.Fatalf("answer = %q", reply.Answer) - } -} - -// A model often narrates before it calls a tool. That text is not the -// answer, so each round is announced and the UI drops what it holds. -func TestCompleteMarksEachRoundSoStaleTokensAreDropped(t *testing.T) { - round := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - fl := w.(http.Flusher) - write := func(v map[string]any) { - b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": v}}}) - fmt.Fprintf(w, "data: %s\n\n", b) - fl.Flush() - } - round++ - if round == 1 { - write(map[string]any{"content": "Let me check the drivers."}) - write(map[string]any{"tool_calls": []any{map[string]any{ - "index": 0, "id": "call_1", "type": "function", - "function": map[string]string{"name": "get_driver_health", "arguments": "{}"}, - }}}) - } else { - write(map[string]any{"content": "## Answer\nSungrow is offline."}) - } - fmt.Fprintf(w, "data: [DONE]\n\n") - fl.Flush() - })) - defer srv.Close() - - var kinds []string - var deltas []string - cli := &Client{HTTP: srv.Client()} - reply, err := cli.Complete(context.Background(), Request{ - APIKey: "k", - BaseURL: srv.URL, - Question: "why is sungrow down?", - Run: func(name string, args json.RawMessage) (string, error) { - return "sungrow status=offline", nil - }, - Progress: func(kind, text string) { - kinds = append(kinds, kind) - if kind == "delta" { - deltas = append(deltas, text) - } - }, - }) - if err != nil { - t.Fatal(err) - } - if reply.Answer != "Sungrow is offline." { - t.Fatalf("answer = %q", reply.Answer) - } - // Two rounds ran, so the UI was told twice to start a fresh answer. - rounds := 0 - for _, k := range kinds { - if k == "round" { - rounds++ - } - } - if rounds != 2 { - t.Fatalf("round markers = %d, want 2; kinds = %v", rounds, kinds) - } - // The narration streamed, but a round marker separates it from the answer. - if len(deltas) != 2 || deltas[0] != "Let me check the drivers." { - t.Fatalf("deltas = %#v", deltas) - } - firstDelta := -1 - secondRound := -1 - seenRound := 0 - for i, k := range kinds { - if k == "delta" && firstDelta < 0 { - firstDelta = i - } - if k == "round" { - seenRound++ - if seenRound == 2 { - secondRound = i - } - } - } - if !(firstDelta < secondRound) { - t.Fatalf("the second round must follow the narration; kinds = %v", kinds) - } -} - -func TestCompleteMapsUnauthorized(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer srv.Close() - cli := &Client{HTTP: srv.Client()} - _, err := cli.Complete(context.Background(), Request{ - APIKey: "bad", Model: DefaultModel, BaseURL: srv.URL, Report: "x", - }) - apiErr, ok := err.(*APIError) - if !ok || apiErr.Status != http.StatusUnauthorized { - t.Fatalf("err = %v", err) - } -} - -func TestCompleteRejectsEmptyKey(t *testing.T) { - cli := &Client{} - _, err := cli.Complete(context.Background(), Request{Report: "x"}) - apiErr, ok := err.(*APIError) - if !ok || apiErr.Status != http.StatusConflict { - t.Fatalf("err = %v", err) - } -} - -func TestSanitizeHistoryKeepsUserAndAssistant(t *testing.T) { - got := SanitizeHistory([]Turn{ - {Role: "system", Text: "ignore"}, - {Role: "user", Text: "why charge?"}, - {Role: "assistant", Text: "Cheap hour."}, - {Role: "tool", Text: "nope"}, - {Role: "user", Text: ""}, - }) - if len(got) != 2 || got[0].Role != "user" || got[1].Role != "assistant" { - t.Fatalf("got %#v", got) - } -} - -func TestSanitizeHistoryCapsLength(t *testing.T) { - long := strings.Repeat("x", maxHistoryRunes+20) - got := SanitizeHistory([]Turn{{Role: "user", Text: long}}) - if len(got) != 1 { - t.Fatalf("len = %d", len(got)) - } - if utf8.RuneCountInString(got[0].Text) != maxHistoryRunes+1 { // plus ellipsis - t.Fatalf("len = %d", utf8.RuneCountInString(got[0].Text)) - } - if !strings.HasSuffix(got[0].Text, "…") { - t.Fatalf("missing ellipsis: %q", got[0].Text) - } -} - -func TestCompleteIncludesHistory(t *testing.T) { - var gotBody string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - gotBody = string(raw) - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "## Answer\nStill cheap.\n"}}, - }, - }) - })) - defer srv.Close() - cli := &Client{HTTP: srv.Client()} - _, err := cli.Complete(context.Background(), Request{ - APIKey: "k", - BaseURL: srv.URL, - Question: "what about tomorrow?", - Report: "idle", - History: []Turn{ - {Role: "user", Text: "why charge?"}, - {Role: "assistant", Text: "Cheap hour."}, - }, - }) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(gotBody, "why charge?") || !strings.Contains(gotBody, "Cheap hour.") { - t.Fatalf("history missing from prompt: %s", gotBody) - } - if !strings.Contains(gotBody, "what about tomorrow?") { - t.Fatalf("current question missing: %s", gotBody) - } -} - -func TestCompleteRunsReadOnlyTools(t *testing.T) { - var saw []string - var called []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - saw = append(saw, string(raw)) - if strings.Contains(string(raw), `"role":"tool"`) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "model": "openrouter/free", - "choices": []map[string]any{ - {"message": map[string]string{ - "role": "assistant", - "content": "## Answer\nSungrow is offline.\n\n## Issue title\n\n## Issue body\n", - }}, - }, - }) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{ - {"message": map[string]any{ - "role": "assistant", - "tool_calls": []map[string]any{ - { - "id": "call_1", - "type": "function", - "function": map[string]string{ - "name": "get_driver_health", - "arguments": `{"name":"sungrow"}`, - }, - }, - { - "id": "call_bad", - "type": "function", - "function": map[string]string{ - "name": "driver_command", - "arguments": `{"action":"battery"}`, - }, - }, - }, - }}, - }, - }) - })) - defer srv.Close() - - cli := &Client{HTTP: srv.Client()} - reply, err := cli.Complete(context.Background(), Request{ - APIKey: "k", - BaseURL: srv.URL, - Question: "why is sungrow down?", - Trigger: "driver sungrow is offline", - Run: func(name string, args json.RawMessage) (string, error) { - called = append(called, name) - return "sungrow status=offline last_success=12 min ago", nil - }, - }) - if err != nil { - t.Fatal(err) - } - if reply.Answer != "Sungrow is offline." { - t.Fatalf("answer = %q", reply.Answer) - } - if reply.ToolRounds != 2 { - t.Fatalf("tool rounds = %d, want 2 (health + rejected write)", reply.ToolRounds) - } - if len(called) != 1 || called[0] != ToolDriverHealth { - t.Fatalf("runner called %v, want only get_driver_health", called) - } - if !strings.Contains(saw[0], `"name":"get_driver_health"`) { - t.Fatalf("first request missing tools: %s", saw[0]) - } - if !strings.Contains(saw[0], "driver sungrow is offline") { - t.Fatalf("first request missing trigger: %s", saw[0]) - } - if !strings.Contains(saw[1], "unknown tool; Ask why is read-only") { - t.Fatalf("write tool was not refused: %s", saw[1]) - } - if !strings.Contains(saw[1], "sungrow status=offline") { - t.Fatalf("health result missing: %s", saw[1]) - } -} - -func TestAllowedToolIsReadOnly(t *testing.T) { - if AllowedTool("driver_command") || AllowedTool("set_mode") { - t.Fatal("write names must not be allowed") - } - if !AllowedTool(ToolSupportReport) || !AllowedTool(ToolPlanNow) { - t.Fatal("read tools must be allowed") - } -} diff --git a/go/internal/assistant/skill.go b/go/internal/assistant/skill.go deleted file mode 100644 index 1709a252e..000000000 --- a/go/internal/assistant/skill.go +++ /dev/null @@ -1,31 +0,0 @@ -package assistant - -// Skill is the method, not a prompt hidden in the HTTP handler. Edit here -// when the investigation guidance changes; the wire format stays the same. -const Skill = `You are Ask why, a read-only helper on an FTW home energy box. - -FTW is local-first home energy management. Positive watts flow into the site. Negative watts flow out. Grid import is positive. PV is negative. Battery charge is positive. Battery discharge is negative. - -Work from evidence. Use the tools: get_support_report, get_driver_health, get_recent_logs, get_plan_now, get_version. Do not invent readings, plans, or errors that a tool did not return. If a tool is missing what you need, say so. - -You cannot control hardware, change config, or send commands. There is no write tool. Do not tell the operator to bypass safety, disable the site-meter watchdog, or apply planner output directly to hardware. - -Goals, in order: -1. Explain what the box is doing right now and why, in plain language. -2. Say whether this looks like expected control, a site or config problem, or an FTW bug. -3. Draft a GitHub issue only when something looks wrong in FTW or a driver. If the box is doing what it should, say that and leave the issue fields empty. -4. If the operator asked about the plan, explain the current slot and Hours ahead from the snapshot: what it intends, why those hours look that way, and what would change it. This is expected control unless the snapshot shows a fault. Leave the issue fields empty when the plan is doing what it should. - -A site snapshot may already be in the user message. Use tools only if you need more. Earlier turns in this dialog may precede the current question. Answer the latest question using that thread and the current snapshot. - -Reply in this exact markdown shape and nothing else: - -## Answer -plain language for the person looking at the house - -## Issue title -one line; leave empty if this is not a bug - -## Issue body -The complete issue as one markdown document the operator posts as-is. Do not mention GitHub form fields or ask them to fill steps. No IP addresses, serial numbers, credentials, GPS, API keys, or raw config. Name the FTW version. Name the hardware brands if the snapshot has them. -` diff --git a/go/internal/assistant/tools.go b/go/internal/assistant/tools.go deleted file mode 100644 index b40b88296..000000000 --- a/go/internal/assistant/tools.go +++ /dev/null @@ -1,73 +0,0 @@ -package assistant - -import "encoding/json" - -// Read-only tools the model may call. Anything else is refused as unknown. -const ( - ToolSupportReport = "get_support_report" - ToolDriverHealth = "get_driver_health" - ToolRecentLogs = "get_recent_logs" - ToolPlanNow = "get_plan_now" - ToolVersion = "get_version" - maxRounds = 6 - maxToolResult = 20_000 -) - -// Runner executes one allowed tool. The HTTP layer implements it against -// live site state. The assistant package never talks to hardware. -type Runner func(name string, args json.RawMessage) (string, error) - -// ToolDef is the OpenAI-compatible tool schema sent to the model. -type ToolDef struct { - Type string `json:"type"` - Function ToolFn `json:"function"` -} - -// ToolFn is the function part of a ToolDef. -type ToolFn struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` -} - -// ToolDefs is the allow-list advertised to the model. -func ToolDefs() []ToolDef { - return []ToolDef{ - fn(ToolSupportReport, - "The local FTW help report: findings, live power, the current plan slot, devices, versions, and recent warnings. Call this first for a full picture.", - `{"type":"object","properties":{}}`), - fn(ToolDriverHealth, - "Health for one driver (yaml name) or every driver if name is omitted. Status, last success age, error count. No IP addresses or serials.", - `{"type":"object","properties":{"name":{"type":"string","description":"Driver yaml name. Omit for every driver."}}}`), - fn(ToolRecentLogs, - "Recent warning and error log lines. Optional driver yaml name. Lines are redacted.", - `{"type":"object","properties":{"driver":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":80}}}`), - fn(ToolPlanNow, - "What the box is doing this minute: mode, live power, the plan slot covering now, and the last commands sent.", - `{"type":"object","properties":{}}`), - fn(ToolVersion, - "FTW version running on this box.", - `{"type":"object","properties":{}}`), - } -} - -func fn(name, desc, params string) ToolDef { - return ToolDef{ - Type: "function", - Function: ToolFn{ - Name: name, - Description: desc, - Parameters: json.RawMessage(params), - }, - } -} - -// AllowedTool is true only for the read-only tools advertised above. -func AllowedTool(name string) bool { - switch name { - case ToolSupportReport, ToolDriverHealth, ToolRecentLogs, ToolPlanNow, ToolVersion: - return true - default: - return false - } -} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 9e29351c3..f891e4fd8 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -44,7 +44,6 @@ type Config struct { Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"` DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"` OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` - Assistant *Assistant `yaml:"assistant,omitempty" json:"assistant,omitempty"` // LoadWarnings collects recoverable problems Parse repaired instead of // refusing the file: an on-disk config an older version accepted must @@ -345,78 +344,6 @@ const ( legacyDriverRepositoryManifestURL = "https://github.com/srcfl/ftw/releases/download/drivers-stable/manifest.json" ) -// Assistant is the optional Ask why helper: a user-supplied OpenRouter -// (or OpenAI-compatible) key used to explain the local help report. -// Nil or disabled is the unavailable state. The helper never issues -// driver commands or writes config. -type Assistant struct { - Enabled bool `yaml:"enabled" json:"enabled"` - APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"` - // Model is an OpenRouter model id. Empty means openrouter/free. - Model string `yaml:"model,omitempty" json:"model,omitempty"` - // BaseURL is the OpenAI-compatible root (no /chat/completions suffix). - // Empty means https://openrouter.ai/api/v1. A later Sourceful proxy - // can set this without a code change. - BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` - - // HasAPIKey is JSON-only: true when a key exists on disk. Set by - // MaskSecrets before APIKey is blanked so Settings can render - // "configured — hidden". Never written to YAML. - HasAPIKey bool `yaml:"-" json:"has_api_key,omitempty"` -} - -const ( - DefaultAssistantModel = "openrouter/free" - DefaultAssistantBaseURL = "https://openrouter.ai/api/v1" -) - -// Ready is true when Ask why can make an outbound call. -func (a *Assistant) Ready() bool { - return a != nil && a.Enabled && strings.TrimSpace(a.APIKey) != "" -} - -// ResolvedModel returns the configured model or the free-router default. -func (a *Assistant) ResolvedModel() string { - if a == nil || strings.TrimSpace(a.Model) == "" { - return DefaultAssistantModel - } - return strings.TrimSpace(a.Model) -} - -// ResolvedBaseURL returns the OpenAI-compatible root without a trailing slash. -func (a *Assistant) ResolvedBaseURL() string { - if a == nil || strings.TrimSpace(a.BaseURL) == "" { - return DefaultAssistantBaseURL - } - return strings.TrimRight(strings.TrimSpace(a.BaseURL), "/") -} - -// Validate checks optional assistant settings. A missing key is allowed: -// the Ask why endpoint reports that as unavailable rather than refusing -// the whole config. -func (a *Assistant) Validate() error { - if a == nil { - return nil - } - if strings.TrimSpace(a.Model) != "" && len(a.Model) > 120 { - return errors.New("assistant.model is too long") - } - if strings.TrimSpace(a.BaseURL) == "" { - return nil - } - u, err := url.Parse(strings.TrimSpace(a.BaseURL)) - if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { - return errors.New("assistant.base_url must be an http(s) URL") - } - if u.User != nil { - return errors.New("assistant.base_url must carry no credentials") - } - if u.RawQuery != "" || u.Fragment != "" { - return errors.New("assistant.base_url must carry no query or fragment") - } - return nil -} - // Notifications configures outbound push notifications. Exactly one // transport provider is active at a time, selected by Provider. Today // the only implemented provider is "ntfy" (ntfy.sh or self-hosted); @@ -1385,12 +1312,6 @@ func (c Config) MaskSecrets() Config { cp.APIKey = "" out.Weather = &cp } - if out.Assistant != nil { - cp := *out.Assistant - cp.HasAPIKey = strings.TrimSpace(cp.APIKey) != "" - cp.APIKey = "" - out.Assistant = &cp - } if out.Notifications != nil { cp := *out.Notifications if cp.Ntfy != nil { @@ -1483,9 +1404,6 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) { if incoming.Weather != nil && existing.Weather != nil && incoming.Weather.APIKey == "" { incoming.Weather.APIKey = existing.Weather.APIKey } - if incoming.Assistant != nil && existing.Assistant != nil && incoming.Assistant.APIKey == "" { - incoming.Assistant.APIKey = existing.Assistant.APIKey - } if incoming.Notifications != nil && existing.Notifications != nil && incoming.Notifications.Ntfy != nil && existing.Notifications.Ntfy != nil { if incoming.Notifications.Ntfy.AccessToken == "" { @@ -2016,9 +1934,6 @@ func (c *Config) Validate() error { if err := c.OCPP.Validate(); err != nil { return err } - if err := c.Assistant.Validate(); err != nil { - return err - } if err := c.validateVehicles(); err != nil { return err } diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index d5a4658d1..a8aa160a2 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -1387,44 +1387,6 @@ api: { port: 8080 } } } -func TestAssistantMaskAndPreserveKey(t *testing.T) { - c := Config{Assistant: &Assistant{Enabled: true, APIKey: "sk-or-v1-secret", Model: "openrouter/free"}} - m := c.MaskSecrets() - if m.Assistant.APIKey != "" { - t.Errorf("key not blanked: %q", m.Assistant.APIKey) - } - if !m.Assistant.HasAPIKey { - t.Error("HasAPIKey should be true after masking a stored key") - } - if c.Assistant.APIKey != "sk-or-v1-secret" { - t.Error("MaskSecrets mutated the source") - } - incoming := &Config{Assistant: &Assistant{Enabled: true, Model: "openrouter/free"}} - incoming.PreserveMaskedSecrets(&c) - if incoming.Assistant.APIKey != "sk-or-v1-secret" { - t.Errorf("key not restored: %q", incoming.Assistant.APIKey) - } -} - -func TestAssistantValidateRejectsCredentialsInBaseURL(t *testing.T) { - a := &Assistant{BaseURL: "https://user:pass@proxy.example/v1"} - if err := a.Validate(); err == nil || !strings.Contains(err.Error(), "credentials") { - t.Fatalf("err = %v", err) - } -} - -func TestAssistantReady(t *testing.T) { - if (*Assistant)(nil).Ready() { - t.Fatal("nil should not be ready") - } - if (&Assistant{Enabled: true}).Ready() { - t.Fatal("enabled without key should not be ready") - } - if !(&Assistant{Enabled: true, APIKey: "k"}).Ready() { - t.Fatal("enabled with key should be ready") - } -} - func TestNotificationsPreserveMaskedSecrets(t *testing.T) { existing := &Config{Notifications: &Notifications{Provider: "ntfy", Ntfy: &NtfyConfig{AccessToken: "real_tok", Password: "real_pw"}}} incoming := &Config{Notifications: &Notifications{Provider: "ntfy", Ntfy: &NtfyConfig{}}} diff --git a/go/internal/config/restart_required_test.go b/go/internal/config/restart_required_test.go index 71c6de100..6fceb818b 100644 --- a/go/internal/config/restart_required_test.go +++ b/go/internal/config/restart_required_test.go @@ -174,15 +174,6 @@ func TestRestartRequiredFor_PriceZoneSwapNeedsRestart(t *testing.T) { } } -func TestRestartRequiredFor_AssistantIsHot(t *testing.T) { - old := baseCfg() - n := baseCfg() - n.Assistant = &Assistant{Enabled: true, APIKey: "sk-or-v1-test", Model: "openrouter/free"} - if r := RestartRequiredFor(old, n); len(r) != 0 { - t.Fatalf("assistant is read per request, got restart reasons %v", r) - } -} - func TestRestartRequiredFor_NilInputs(t *testing.T) { if r := RestartRequiredFor(nil, baseCfg()); r != nil { t.Fatalf("expected nil reasons for nil old, got %v", r) diff --git a/go/internal/config/storage.go b/go/internal/config/storage.go index 68351d179..7af053d14 100644 --- a/go/internal/config/storage.go +++ b/go/internal/config/storage.go @@ -205,6 +205,27 @@ func SaveStored(st *state.Store, path string, cfg *Config) error { return saveStored(st, path, cfg, "") } +// DropRetiredSettings rewrites stored settings that still carry the block of +// a removed feature. Ask why kept an OpenRouter key there; nothing reads it, +// so it must not stay in state.db and every backup. Saving the typed Config +// writes the document without it. It reports whether it rewrote anything. +func DropRetiredSettings(st *state.Store, path string, cfg *Config) (bool, error) { + current, found, err := st.Configuration() + if err != nil || !found { + return false, err + } + var saved struct { + Config map[string]json.RawMessage `json:"config"` + } + if err := json.Unmarshal(current.Document, &saved); err != nil { + return false, err + } + if _, ok := saved.Config["assistant"]; !ok { + return false, nil + } + return true, SaveStored(st, path, cfg) +} + func saveStored(st *state.Store, path string, cfg *Config, sourceHash string) error { if cfg.ConfigDatabase == "" { return errors.New("settings database is not initialized") diff --git a/go/internal/config/storage_test.go b/go/internal/config/storage_test.go index 7673e1c17..e3a7a13a7 100644 --- a/go/internal/config/storage_test.go +++ b/go/internal/config/storage_test.go @@ -421,3 +421,66 @@ func TestRecoveryCannotSubstituteAnotherConfigAtTheSameRevision(t *testing.T) { t.Fatal("different config with the same revision replaced the loaded settings") } } + +func TestDropRetiredSettingsDeletesTheAskWhyKey(t *testing.T) { + dir := t.TempDir() + path, database := filepath.Join(dir, "config.yaml"), filepath.Join(dir, "state.db") + if err := os.WriteFile(path, []byte(minimalYAML), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + st, err := state.Open(database) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := InitializeStorage(path, database, cfg, st); err != nil { + t.Fatal(err) + } + // Settings saved by a Core that still had Ask why. + current, _, err := st.Configuration() + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(current.Document, &doc); err != nil { + t.Fatal(err) + } + doc["config"].(map[string]any)["assistant"] = map[string]any{"enabled": true, "api_key": "sk-or-v1-secret"} + raw, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if _, err := st.SaveConfiguration(raw, current.Revision, nil); err != nil { + t.Fatal(err) + } + loaded, err := loadStored(database, dir) + if err != nil { + t.Fatal(err) + } + + dropped, err := DropRetiredSettings(st, path, loaded) + if err != nil || !dropped { + t.Fatalf("DropRetiredSettings = %v, %v; want true", dropped, err) + } + after, _, err := st.Configuration() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(after.Document), "sk-or-v1-secret") || strings.Contains(string(after.Document), `"assistant"`) { + t.Fatalf("stored settings still carry Ask why: %s", after.Document) + } + reloaded, err := loadStored(database, dir) + if err != nil { + t.Fatal(err) + } + if reloaded.Site.Name != loaded.Site.Name { + t.Fatalf("site name %q, want %q", reloaded.Site.Name, loaded.Site.Name) + } + if dropped, err := DropRetiredSettings(st, path, reloaded); err != nil || dropped { + t.Fatalf("second DropRetiredSettings = %v, %v; want false", dropped, err) + } +} diff --git a/go/internal/state/assistant_threads.go b/go/internal/state/assistant_threads.go deleted file mode 100644 index 443a0a2f4..000000000 --- a/go/internal/state/assistant_threads.go +++ /dev/null @@ -1,187 +0,0 @@ -package state - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - "unicode/utf8" -) - -// Ask why conversations live on the box, not in a browser: the box is the -// record, and an operator who asked from a laptop should find the answer -// from a phone. -// -// A house keeps a small number of these. The cap exists because the box -// is usually a Raspberry Pi on an SD card, where unbounded append is a -// wear problem long before it is a space problem. -const ( - // AssistantThreadCap is how many conversations the box keeps. - AssistantThreadCap = 50 - // assistantTitleRunes caps the title taken from the first question. - assistantTitleRunes = 80 - // assistantThreadRunes caps one stored conversation. A runaway model - // must not be able to fill the card. - assistantThreadRunes = 60_000 -) - -// AssistantTurn is one line of an Ask why conversation. -type AssistantTurn struct { - Role string `json:"role"` - Text string `json:"text"` - TsMs int64 `json:"ts_ms,omitempty"` -} - -// AssistantThread is one conversation. Turns is empty in list results. -type AssistantThread struct { - ID string `json:"id"` - StartedMs int64 `json:"started_ms"` - UpdatedMs int64 `json:"updated_ms"` - Title string `json:"title"` - Model string `json:"model,omitempty"` - TurnCount int `json:"turn_count"` - Turns []AssistantTurn `json:"turns,omitempty"` -} - -// SaveAssistantThread writes a conversation and prunes the oldest beyond -// AssistantThreadCap. The id is chosen by the caller so a reply can name -// the thread it belongs to. -func (s *Store) SaveAssistantThread(t AssistantThread) error { - id := strings.TrimSpace(t.ID) - if id == "" { - return fmt.Errorf("assistant thread: empty id") - } - now := time.Now().UnixMilli() - if t.StartedMs == 0 { - t.StartedMs = now - } - t.UpdatedMs = now - t.Title = assistantTitle(t.Title, t.Turns) - - raw, err := json.Marshal(trimAssistantTurns(t.Turns)) - if err != nil { - return fmt.Errorf("assistant thread marshal: %w", err) - } - if _, err := s.db.Exec( - `INSERT INTO assistant_threads (id, started_ms, updated_ms, title, model, turns_json) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - updated_ms = excluded.updated_ms, - title = excluded.title, - model = excluded.model, - turns_json = excluded.turns_json`, - id, t.StartedMs, t.UpdatedMs, t.Title, t.Model, string(raw)); err != nil { - return fmt.Errorf("assistant_threads insert: %w", err) - } - // Keep the newest AssistantThreadCap rows. Ties on updated_ms break on - // id so the delete is deterministic. - if _, err := s.db.Exec( - `DELETE FROM assistant_threads WHERE id NOT IN ( - SELECT id FROM assistant_threads ORDER BY updated_ms DESC, id DESC LIMIT ? - )`, AssistantThreadCap); err != nil { - return fmt.Errorf("assistant_threads prune: %w", err) - } - return nil -} - -// RecentAssistantThreads lists conversations newest first, without turns. -func (s *Store) RecentAssistantThreads(limit int) ([]AssistantThread, error) { - if limit <= 0 || limit > AssistantThreadCap { - limit = AssistantThreadCap - } - rows, err := s.db.Query( - `SELECT id, started_ms, updated_ms, title, model, turns_json - FROM assistant_threads ORDER BY updated_ms DESC LIMIT ?`, limit) - if err != nil { - return nil, err - } - defer rows.Close() - out := make([]AssistantThread, 0, limit) - for rows.Next() { - var t AssistantThread - var raw string - if err := rows.Scan(&t.ID, &t.StartedMs, &t.UpdatedMs, &t.Title, &t.Model, &raw); err != nil { - return out, err - } - var turns []AssistantTurn - if err := json.Unmarshal([]byte(raw), &turns); err == nil { - t.TurnCount = len(turns) - } - out = append(out, t) - } - return out, rows.Err() -} - -// AssistantThreadByID returns one conversation with its turns. The bool is -// false when no such thread exists. -func (s *Store) AssistantThreadByID(id string) (AssistantThread, bool, error) { - var t AssistantThread - var raw string - err := s.db.QueryRow( - `SELECT id, started_ms, updated_ms, title, model, turns_json - FROM assistant_threads WHERE id = ?`, strings.TrimSpace(id)). - Scan(&t.ID, &t.StartedMs, &t.UpdatedMs, &t.Title, &t.Model, &raw) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return AssistantThread{}, false, nil - } - return AssistantThread{}, false, err - } - if err := json.Unmarshal([]byte(raw), &t.Turns); err != nil { - t.Turns = nil - } - t.TurnCount = len(t.Turns) - return t, true, nil -} - -// DeleteAssistantThread removes one conversation. Deleting a thread that -// is already gone is not an error. -func (s *Store) DeleteAssistantThread(id string) error { - _, err := s.db.Exec(`DELETE FROM assistant_threads WHERE id = ?`, strings.TrimSpace(id)) - return err -} - -// DeleteAllAssistantThreads clears the history. -func (s *Store) DeleteAllAssistantThreads() error { - _, err := s.db.Exec(`DELETE FROM assistant_threads`) - return err -} - -// assistantTitle names a thread by its first question. -func assistantTitle(given string, turns []AssistantTurn) string { - title := strings.TrimSpace(given) - if title == "" { - for _, t := range turns { - if t.Role == "user" && strings.TrimSpace(t.Text) != "" { - title = strings.TrimSpace(t.Text) - break - } - } - } - title = strings.Join(strings.Fields(title), " ") - if title == "" { - return "Ask why" - } - if utf8.RuneCountInString(title) > assistantTitleRunes { - title = string([]rune(title)[:assistantTitleRunes-1]) + "…" - } - return title -} - -// trimAssistantTurns drops the oldest turns until the conversation fits. -func trimAssistantTurns(turns []AssistantTurn) []AssistantTurn { - total := 0 - for _, t := range turns { - total += utf8.RuneCountInString(t.Text) - } - for len(turns) > 1 && total > assistantThreadRunes { - total -= utf8.RuneCountInString(turns[0].Text) - turns = turns[1:] - } - if len(turns) == 1 && utf8.RuneCountInString(turns[0].Text) > assistantThreadRunes { - turns[0].Text = string([]rune(turns[0].Text)[:assistantThreadRunes]) + "…" - } - return turns -} diff --git a/go/internal/state/assistant_threads_test.go b/go/internal/state/assistant_threads_test.go deleted file mode 100644 index d49d27aba..000000000 --- a/go/internal/state/assistant_threads_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package state - -import ( - "fmt" - "strings" - "testing" -) - -func askThread(id, question, answer string) AssistantThread { - return AssistantThread{ - ID: id, - Turns: []AssistantTurn{ - {Role: "user", Text: question}, - {Role: "assistant", Text: answer}, - }, - } -} - -func TestAssistantThreadRoundTrips(t *testing.T) { - s := openTestStore(t) - if err := s.SaveAssistantThread(askThread("aaaa000000000001", "why is it charging?", "cheap slot")); err != nil { - t.Fatal(err) - } - got, ok, err := s.AssistantThreadByID("aaaa000000000001") - if err != nil || !ok { - t.Fatalf("read back: ok=%v err=%v", ok, err) - } - if got.Title != "why is it charging?" { - t.Fatalf("title = %q, want the first question", got.Title) - } - if len(got.Turns) != 2 || got.Turns[1].Text != "cheap slot" { - t.Fatalf("turns = %#v", got.Turns) - } - if got.UpdatedMs == 0 || got.StartedMs == 0 { - t.Fatalf("timestamps not set: %+v", got) - } -} - -func TestAssistantThreadMissingIsNotAnError(t *testing.T) { - s := openTestStore(t) - _, ok, err := s.AssistantThreadByID("ffff000000000000") - if err != nil { - t.Fatalf("err = %v, want nil for a missing thread", err) - } - if ok { - t.Fatal("ok = true for a thread that was never written") - } -} - -// A follow-up must land in the same row, not start a second one. -func TestAssistantThreadFollowUpUpdatesInPlace(t *testing.T) { - s := openTestStore(t) - id := "bbbb000000000001" - if err := s.SaveAssistantThread(askThread(id, "first", "one")); err != nil { - t.Fatal(err) - } - grown := askThread(id, "first", "one") - grown.Turns = append(grown.Turns, - AssistantTurn{Role: "user", Text: "second"}, - AssistantTurn{Role: "assistant", Text: "two"}) - if err := s.SaveAssistantThread(grown); err != nil { - t.Fatal(err) - } - list, err := s.RecentAssistantThreads(0) - if err != nil { - t.Fatal(err) - } - if len(list) != 1 { - t.Fatalf("threads = %d, want 1 row after a follow-up", len(list)) - } - if list[0].TurnCount != 4 { - t.Fatalf("turn count = %d, want 4", list[0].TurnCount) - } - if list[0].Title != "first" { - t.Fatalf("title = %q, want the first question to stick", list[0].Title) - } -} - -// The box is usually a Pi on an SD card. History cannot grow forever. -func TestAssistantThreadsAreCappedNewestFirst(t *testing.T) { - s := openTestStore(t) - for i := 0; i < AssistantThreadCap+7; i++ { - id := fmt.Sprintf("cccc%012d", i) - if err := s.SaveAssistantThread(askThread(id, fmt.Sprintf("question %d", i), "answer")); err != nil { - t.Fatal(err) - } - } - list, err := s.RecentAssistantThreads(0) - if err != nil { - t.Fatal(err) - } - if len(list) != AssistantThreadCap { - t.Fatalf("kept %d threads, want the cap of %d", len(list), AssistantThreadCap) - } - // The oldest seven are the ones that went. - for _, th := range list { - if th.Title == "question 0" { - t.Fatal("the oldest thread survived the cap") - } - } -} - -func TestAssistantThreadDeletes(t *testing.T) { - s := openTestStore(t) - if err := s.SaveAssistantThread(askThread("dddd000000000001", "q", "a")); err != nil { - t.Fatal(err) - } - if err := s.DeleteAssistantThread("dddd000000000001"); err != nil { - t.Fatal(err) - } - if _, ok, _ := s.AssistantThreadByID("dddd000000000001"); ok { - t.Fatal("thread survived delete") - } - // Deleting again is not an error. - if err := s.DeleteAssistantThread("dddd000000000001"); err != nil { - t.Fatalf("second delete: %v", err) - } -} - -func TestAssistantThreadsClear(t *testing.T) { - s := openTestStore(t) - for i := 0; i < 3; i++ { - if err := s.SaveAssistantThread(askThread(fmt.Sprintf("eeee%012d", i), "q", "a")); err != nil { - t.Fatal(err) - } - } - if err := s.DeleteAllAssistantThreads(); err != nil { - t.Fatal(err) - } - list, err := s.RecentAssistantThreads(0) - if err != nil { - t.Fatal(err) - } - if len(list) != 0 { - t.Fatalf("threads = %d after clear, want 0", len(list)) - } -} - -// A model that will not stop writing must not fill the card. -func TestAssistantThreadTrimsARunawayAnswer(t *testing.T) { - s := openTestStore(t) - huge := strings.Repeat("x", assistantThreadRunes*2) - if err := s.SaveAssistantThread(askThread("aaaa000000000009", "q", huge)); err != nil { - t.Fatal(err) - } - got, ok, err := s.AssistantThreadByID("aaaa000000000009") - if err != nil || !ok { - t.Fatalf("read back: ok=%v err=%v", ok, err) - } - total := 0 - for _, turn := range got.Turns { - total += len([]rune(turn.Text)) - } - if total > assistantThreadRunes+8 { - t.Fatalf("stored %d runes, want the thread capped near %d", total, assistantThreadRunes) - } -} - -func TestAssistantThreadTitleFallsBackWhenNoQuestion(t *testing.T) { - got := assistantTitle("", []AssistantTurn{{Role: "assistant", Text: "only an answer"}}) - if got != "Ask why" { - t.Fatalf("title = %q, want the fallback", got) - } -} - -func TestAssistantThreadTitleCollapsesWhitespace(t *testing.T) { - got := assistantTitle(" why is\nit\tcharging? ", nil) - if got != "why is it charging?" { - t.Fatalf("title = %q", got) - } -} - -func TestAssistantThreadRejectsEmptyID(t *testing.T) { - s := openTestStore(t) - if err := s.SaveAssistantThread(askThread("", "q", "a")); err == nil { - t.Fatal("an empty id was accepted") - } -} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index 97e364e82..2b311357c 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -997,20 +997,6 @@ func (s *Store) migrate() error { json TEXT NOT NULL ) STRICT`, - // Ask why conversations. One row per thread; the turns are JSON - // because a thread is read and written whole and is never queried - // by its contents. Capped at AssistantThreadCap rows on write — - // see assistant_threads.go for why the box does not keep them all. - `CREATE TABLE IF NOT EXISTS assistant_threads ( - id TEXT PRIMARY KEY NOT NULL, - started_ms INTEGER NOT NULL, - updated_ms INTEGER NOT NULL, - title TEXT NOT NULL DEFAULT '', - model TEXT NOT NULL DEFAULT '', - turns_json TEXT NOT NULL - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_assistant_threads_updated ON assistant_threads(updated_ms)`, - // Nova federation: one row per local DER we've provisioned in Nova. // Keyed on (device_id, der_type) so a hybrid inverter with multiple // DERs (battery + pv + meter on the same device_id) has one row per @@ -1126,6 +1112,10 @@ func (s *Store) migrate() error { // relying party that no longer exists, so they authorise nothing, and // a migration that deletes rows is the one kind that cannot be undone // if this turns out to have been wrong. Do not reuse these names. + // + // The assistant_threads table went the same way when Ask why was + // removed. Its rows are old conversations that nothing reads. Do not + // reuse the name. } for _, stmt := range stmts { diff --git a/web/app.css b/web/app.css index c3330000c..46d18f042 100644 --- a/web/app.css +++ b/web/app.css @@ -127,27 +127,6 @@ body.ftw-app select:focus-visible { body.ftw-app .header-right { gap: 10px; } -/* Offline chip lives outside .header-right so it stays in the header - row on phones, where the right cluster folds into the hamburger. */ -body.ftw-app .ask-why-chip { - max-width: min(42vw, 280px); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--sans); - font-size: 12px; - font-weight: 500; - letter-spacing: 0.01em; - padding: 6px 10px; - border: 1px solid var(--red-e, #ef4444); - border-radius: 999px; - background: transparent; - color: var(--red-e, #ef4444); - cursor: pointer; -} -body.ftw-app .ask-why-chip:hover { - border-color: var(--fg); -} body.ftw-app .version { font-family: var(--mono); font-size: 13px; @@ -1653,32 +1632,6 @@ body.ftw-app .plan-now-secondary > small { max-width: 44ch; text-align: right; } -body.ftw-app .plan-ask-why { - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 16px 14px; - border-top: 1px solid var(--line); -} -body.ftw-app .plan-ask-why-row { - display: flex; - gap: 8px; - align-items: center; -} -body.ftw-app .plan-ask-why input { - flex: 1; - min-width: 0; - background: var(--ink); - color: var(--fg); - border: 1px solid var(--line); - border-radius: 8px; - padding: 8px 12px; - font: 0.9rem var(--sans); -} -body.ftw-app .plan-ask-why .btn-send { - flex: 0 0 auto; - white-space: nowrap; -} body.ftw-app .plan-state-badge { flex: 0 0 auto; padding: 4px 7px; @@ -1719,12 +1672,6 @@ body.ftw-app .plan-help-request { padding-top: 10px; border-top: 1px solid var(--border); } -body.ftw-app .plan-help-actions { - display: flex; - flex-wrap: wrap; - gap: 8px 18px; - align-items: center; -} body.ftw-app .plan-help-request button { padding-left: 0; } body.ftw-app .plan-help-request button[disabled] { opacity: 0.6; @@ -2769,15 +2716,6 @@ body.ftw-app .diagnose-detail { body.ftw-app .plan-now-meta > div:last-child { border-bottom: 0; } - body.ftw-app .plan-ask-why { - padding: 10px 12px 12px; - } - body.ftw-app .plan-ask-why-row { - flex-wrap: wrap; - } - body.ftw-app .plan-ask-why input { - min-width: 12rem; - } body.ftw-app .plan-actions { align-items: stretch; flex-wrap: wrap; } body.ftw-app .more-actions { grid-template-columns: 1fr; } body.ftw-app .forecast-trust-help, diff --git a/web/app.js b/web/app.js index a81c956d8..95e0f2cb5 100644 --- a/web/app.js +++ b/web/app.js @@ -2244,8 +2244,6 @@ // Isolate render errors from connection state / timestamp try { render(data); } catch (e) { console.error("render error:", e); } - try { window.dispatchEvent(new CustomEvent("ftw-status", { detail: data })); } - catch (e3) { /* assistant chip is optional */ } // Show a subtle prompt when no drivers are configured try { updateNoDevicesPrompt(data.drivers); } catch (e2) { /* silent */ } diff --git a/web/assistant.js b/web/assistant.js deleted file mode 100644 index 67fc0ce11..000000000 --- a/web/assistant.js +++ /dev/null @@ -1,756 +0,0 @@ -// Ask why — a short conversation about this box. -// -// The question is a chat line. The answer is a separate message. Progress -// streams while the model works. Opening a GitHub issue uses one filled -// markdown body, not the YAML bug form. -(function () { - "use strict"; - - var ISSUE_NEW = "https://github.com/srcfl/ftw/issues/new?template=ask_why.md"; - - function apiFetch(path, opts) { - return fetch(path, opts); - } - - function escHtml(s) { - var div = document.createElement("div"); - div.textContent = s == null ? "" : String(s); - return div.innerHTML; - } - - // Inline marks. Code spans are lifted out first so ** and * inside a - // span stay literal. - function inlineMd(s) { - var codes = []; - s = escHtml(s); - s = s.replace(/`([^`]+)`/g, function (m, c) { - codes.push(c); - return "\u0000" + (codes.length - 1) + "\u0000"; - }); - s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); - s = s.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2"); - return s.replace(/\u0000(\d+)\u0000/g, function (m, i) { - return "" + codes[Number(i)] + ""; - }); - } - - // The model is asked for markdown, so render markdown: headings, both - // kinds of list, fenced code and paragraphs. - function formatAnswer(text) { - var lines = String(text == null ? "" : text).replace(/\r\n/g, "\n").split("\n"); - var out = []; - var para = []; - var listTag = null; - var items = []; - var code = null; - function flushPara() { - if (!para.length) return; - out.push("

" + inlineMd(para.join("\n")).replace(/\n/g, "
") + "

"); - para = []; - } - function flushList() { - if (!listTag) return; - out.push("<" + listTag + ">" + items.map(function (i) { - return "
  • " + inlineMd(i) + "
  • "; - }).join("") + ""); - listTag = null; - items = []; - } - function flushAll() { flushPara(); flushList(); } - function openList(tag, item) { - flushPara(); - if (listTag !== tag) { flushList(); listTag = tag; } - items.push(item); - } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (code !== null) { - if (/^\s*```/.test(line)) { - out.push("
    " + escHtml(code.join("\n")) + "
    "); - code = null; - } else { - code.push(line); - } - continue; - } - if (/^\s*```/.test(line)) { flushAll(); code = []; continue; } - if (!line.trim()) { flushAll(); continue; } - var head = line.match(/^\s*(#{1,4})\s+(.*)$/); - if (head) { - flushAll(); - var lvl = Math.min(head[1].length + 2, 6); - out.push("" + inlineMd(head[2].trim()) + ""); - continue; - } - var bullet = line.match(/^\s*[-*•]\s+(.*)$/); - if (bullet) { openList("ul", bullet[1]); continue; } - var numbered = line.match(/^\s*\d+[.)]\s+(.*)$/); - if (numbered) { openList("ol", numbered[1]); continue; } - flushList(); - para.push(line); - } - if (code !== null) { - out.push("
    " + escHtml(code.join("\n")) + "
    "); - } - flushAll(); - return out.join(""); - } - - function toolLabel(name) { - if (name === "get_driver_health") return "Checking drivers"; - if (name === "get_recent_logs") return "Reading logs"; - if (name === "get_plan_now") return "Reading the plan"; - if (name === "get_support_report") return "Reading the help report"; - if (name === "get_version") return "Reading the version"; - return "Looking further"; - } - - function ensureStyles() { - if (document.getElementById("ftw-ask-styles")) return; - var style = document.createElement("style"); - style.id = "ftw-ask-styles"; - style.textContent = [ - ".ftw-ask-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9000;display:flex;align-items:center;justify-content:center;}", - ".ftw-ask-shell{width:min(680px,94vw);max-height:90vh;min-height:0;display:flex;flex-direction:column;background:var(--ink-raised,#161616);border:1px solid var(--line,#2a2a2a);border-radius:10px;overflow:hidden;}", - ".ftw-ask-head{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line,#2a2a2a);background:var(--ink,#111);}", - ".ftw-ask-title{font-family:var(--mono,monospace);font-size:0.7rem;letter-spacing:0.18em;text-transform:uppercase;color:var(--accent-e,#f5b942);font-weight:500;}", - ".ftw-ask-close{font-size:1.4rem;line-height:1;background:transparent;border:none;color:var(--fg-muted,#858585);cursor:pointer;padding:4px 8px;}", - ".ftw-ask-close:hover{color:var(--fg,#e8e8e8);}", - ".ftw-ask-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;overflow:hidden;}", - ".ftw-ask-thread{padding:16px 18px;overflow:auto;flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;}", - ".ftw-ask-msg{max-width:92%;padding:10px 12px;border-radius:10px;font:0.9rem/1.5 var(--sans,sans-serif);overflow-wrap:anywhere;word-break:break-word;}", - ".ftw-ask-msg.user{align-self:flex-end;background:var(--ink,#111);border:1px solid var(--line,#2a2a2a);color:var(--fg,#e8e8e8);}", - ".ftw-ask-msg.assistant{align-self:flex-start;background:var(--ink-sunken,#0d0d0d);border:1px solid var(--line,#2a2a2a);color:var(--fg,#e8e8e8);}", - ".ftw-ask-msg.assistant strong{color:var(--fg,#e8e8e8);}", - ".ftw-ask-msg.assistant code{font-family:var(--mono,monospace);font-size:0.82em;}", - ".ftw-ask-msg.assistant > *:first-child{margin-top:0;}", - ".ftw-ask-msg.assistant > *:last-child{margin-bottom:0;}", - ".ftw-ask-msg.assistant p{margin:0 0 0.7em;}", - ".ftw-ask-msg.assistant h3,.ftw-ask-msg.assistant h4,.ftw-ask-msg.assistant h5,.ftw-ask-msg.assistant h6{margin:0.9em 0 0.4em;font-size:0.86rem;font-weight:600;color:var(--fg,#e8e8e8);}", - ".ftw-ask-msg.assistant ul,.ftw-ask-msg.assistant ol{margin:0 0 0.7em;padding-left:1.35em;}", - ".ftw-ask-msg.assistant li{margin:0.15em 0;}", - ".ftw-ask-msg.assistant pre{margin:0 0 0.7em;padding:8px 10px;background:var(--ink,#111);border:1px solid var(--line,#2a2a2a);border-radius:6px;overflow-x:auto;}", - ".ftw-ask-msg.assistant pre code{font-size:0.78em;white-space:pre;}", - // The caret is the sign the model is still writing. - ".ftw-ask-msg.is-streaming > *:last-child::after{content:'';display:inline-block;width:0.5em;height:1em;margin-left:2px;vertical-align:text-bottom;background:var(--accent-e,#f5b942);animation:ftw-ask-caret 1s steps(2,start) infinite;}", - "@keyframes ftw-ask-caret{to{visibility:hidden;}}", - "@media (prefers-reduced-motion:reduce){.ftw-ask-msg.is-streaming > *:last-child::after{animation:none;}}", - ".ftw-ask-activity{align-self:flex-start;display:flex;flex-direction:column;gap:4px;padding:2px 0 4px;max-width:100%;}", - ".ftw-ask-step{display:flex;align-items:center;gap:8px;color:var(--fg-dim,#a0a0a0);font:0.78rem/1.4 var(--sans,sans-serif);}", - ".ftw-ask-step::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--fg-muted,#858585);flex:0 0 auto;}", - ".ftw-ask-step.is-live{color:var(--accent-e,#f5b942);}", - ".ftw-ask-step.is-live::before{background:var(--accent-e,#f5b942);box-shadow:0 0 0 3px rgba(245,185,66,0.22);}", - ".ftw-ask-step.is-tool{font-family:var(--mono,monospace);font-size:0.72rem;letter-spacing:0.02em;}", - ".ftw-ask-note{color:var(--fg-dim,#a0a0a0);font-size:0.84rem;line-height:1.45;margin:0;}", - ".ftw-ask-error{color:var(--red-e,#ef4444);font-size:0.84rem;}", - ".ftw-ask-foot{flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;gap:8px;padding:0 18px 10px;flex-wrap:wrap;}", - ".ftw-ask-status{color:var(--fg-dim,#a0a0a0);font-size:0.78rem;}", - ".ftw-ask-composer{flex:0 0 auto;display:flex;gap:8px;padding:12px 18px 16px;border-top:1px solid var(--line,#2a2a2a);}", - ".ftw-ask-composer input{flex:1;min-width:0;background:var(--ink-sunken,#0d0d0d);color:var(--fg,#e8e8e8);border:1px solid var(--line,#2a2a2a);border-radius:8px;padding:10px 12px;font:0.9rem var(--sans,sans-serif);}", - ".ftw-ask-btn{font-family:var(--sans,sans-serif);font-size:0.78rem;font-weight:500;letter-spacing:0.02em;padding:8px 14px;border:1px solid var(--line,#2a2a2a);border-radius:8px;background:transparent;color:var(--fg,#e8e8e8);cursor:pointer;}", - ".ftw-ask-btn:hover{border-color:var(--fg-dim,#a0a0a0);}", - ".ftw-ask-btn[disabled]{opacity:0.55;cursor:default;}", - ".ftw-ask-btn-primary{background:var(--accent-e,#f5b942);color:#0a0a0a;border-color:var(--accent-e,#f5b942);}", - ".ftw-ask-setup{padding:16px 18px;}", - ".ftw-ask-setup a{color:var(--accent-e,#f5b942);}", - ".ftw-ask-head-actions{display:flex;align-items:center;gap:4px;}", - ".ftw-ask-icon{font-family:var(--sans,sans-serif);font-size:0.72rem;letter-spacing:0.02em;background:transparent;border:1px solid var(--line,#2a2a2a);border-radius:6px;color:var(--fg-dim,#a0a0a0);cursor:pointer;padding:4px 9px;}", - ".ftw-ask-icon:hover{color:var(--fg,#e8e8e8);border-color:var(--fg-dim,#a0a0a0);}", - ".ftw-ask-history{list-style:none;margin:0;padding:8px 10px;overflow:auto;flex:1 1 auto;min-height:0;}", - ".ftw-ask-history li{display:flex;align-items:stretch;gap:4px;}", - ".ftw-ask-thread{flex:1;min-width:0;text-align:left;background:transparent;border:none;border-radius:8px;color:var(--fg,#e8e8e8);cursor:pointer;padding:9px 10px;display:flex;flex-direction:column;gap:2px;}", - ".ftw-ask-thread:hover{background:var(--ink-sunken,#0d0d0d);}", - ".ftw-ask-thread-title{font:0.86rem/1.35 var(--sans,sans-serif);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}", - ".ftw-ask-thread-meta{font:0.72rem var(--mono,monospace);color:var(--fg-muted,#858585);}", - ".ftw-ask-thread-del{flex:0 0 auto;background:transparent;border:none;color:var(--fg-muted,#858585);cursor:pointer;padding:0 8px;font-size:1.1rem;line-height:1;border-radius:8px;}", - ".ftw-ask-thread-del:hover{color:var(--red-e,#ef4444);background:var(--ink-sunken,#0d0d0d);}", - ].join(""); - document.head.appendChild(style); - } - - var state = { - backdrop: null, - last: null, - keyHandler: null, - trigger: null, - offlineNames: [], - busy: false, - turns: [], - generation: 0, - abort: null, - threadID: "", - }; - - function offlineDrivers(data) { - var names = []; - var drivers = (data && data.drivers) || {}; - Object.keys(drivers).sort().forEach(function (name) { - var d = drivers[name] || {}; - if (d.status === "offline") names.push(name); - }); - return names; - } - - function updateChip(data) { - var chip = document.getElementById("ask-why-chip"); - if (!chip) return; - var names = offlineDrivers(data); - state.offlineNames = names; - if (names.length === 0) { - chip.hidden = true; - chip.textContent = ""; - chip.removeAttribute("title"); - return; - } - chip.hidden = false; - if (names.length === 1) { - chip.textContent = names[0] + " is offline — Ask why"; - } else { - chip.textContent = names.length + " drivers offline — Ask why"; - } - chip.title = names.join(", ") + " offline"; - } - - function close() { - state.generation += 1; - if (state.abort) { - try { state.abort.abort(); } catch (e) { /* already closed */ } - state.abort = null; - } - if (state.keyHandler) { - document.removeEventListener("keydown", state.keyHandler); - state.keyHandler = null; - } - if (state.backdrop && state.backdrop.parentNode) { - state.backdrop.parentNode.removeChild(state.backdrop); - } - state.backdrop = null; - state.busy = false; - state.turns = []; - state.threadID = ""; - } - - function stillOpen(gen) { - return gen === state.generation && !!state.backdrop; - } - - // Abort the in-flight question but keep the dialog and the thread. - // close() aborts too, but bumps the generation and drops everything. - function stop() { - if (!state.abort) return; - try { state.abort.abort(); } catch (e) { /* already finished */ } - } - - function threadEl() { - return state.backdrop && state.backdrop.querySelector('[data-role="thread"]'); - } - - function addUser(text) { - var thread = threadEl(); - if (!thread) return; - var el = document.createElement("div"); - el.className = "ftw-ask-msg user"; - el.textContent = text; - thread.appendChild(el); - thread.scrollTop = thread.scrollHeight; - } - - function addAssistant(text) { - var thread = threadEl(); - if (!thread) return; - var el = document.createElement("div"); - el.className = "ftw-ask-msg assistant"; - el.innerHTML = formatAnswer(text); - thread.appendChild(el); - thread.scrollTop = thread.scrollHeight; - } - - function visibleAnswer(s) { - s = String(s || ""); - var cut = s.search(/\n#{1,3}\s*Issue title\b/i); - if (cut < 0) cut = s.search(/\n\*\*Issue title\*\*/i); - if (cut >= 0) s = s.slice(0, cut); - s = s.replace(/^#{1,3}\s*Answer\s*\n+/i, ""); - s = s.replace(/^\*\*Answer\*\*\s*\n+/i, ""); - return s; - } - - // One run is one question. Its activity log and draft bubble are held - // by reference, never looked up in the thread: a DOM query would find - // the previous question's nodes and append this answer above the one - // before it. - function newRun() { - return { activity: null, draft: null, streamed: "" }; - } - - function addActivity(run, text, kind) { - var thread = threadEl(); - if (!thread) return; - if (!run.activity) { - run.activity = document.createElement("div"); - run.activity.className = "ftw-ask-activity"; - thread.appendChild(run.activity); - } - var live = run.activity.querySelector(".is-live"); - if (live) live.classList.remove("is-live"); - var el = document.createElement("div"); - el.className = "ftw-ask-step" + (kind === "tool" ? " is-tool is-live" : " is-live"); - el.textContent = text; - run.activity.appendChild(el); - thread.scrollTop = thread.scrollHeight; - } - - function settleActivity(run) { - if (!run.activity) return; - var live = run.activity.querySelector(".is-live"); - if (live) live.classList.remove("is-live"); - } - - // The last line is half-written while tokens arrive. Rendering it would - // flash a partial "## Issue title" as a heading, so hold that line back - // until it ends. - function streamingText(s) { - return visibleAnswer(s).replace(/\n#{1,4}[^\n]*$/, ""); - } - - function appendDelta(run) { - var thread = threadEl(); - if (!thread) return; - if (!run.draft) { - run.draft = document.createElement("div"); - run.draft.className = "ftw-ask-msg assistant is-streaming"; - thread.appendChild(run.draft); - } - // Rendered as it arrives: raw ** and - for the length of a slow free - // model's answer reads as broken output. - run.draft.innerHTML = formatAnswer(streamingText(run.streamed)); - thread.scrollTop = thread.scrollHeight; - } - - // A new model round discards what streamed in the previous one: that - // text led up to a tool call, it is not the answer being written. - function resetDraft(run) { - run.streamed = ""; - if (run.draft && run.draft.parentNode) { - run.draft.parentNode.removeChild(run.draft); - } - run.draft = null; - } - - function finishAssistant(run, text) { - var thread = threadEl(); - if (!run.draft) { - addAssistant(text); - return; - } - run.draft.classList.remove("is-streaming"); - run.draft.innerHTML = formatAnswer(text); - run.draft = null; - if (thread) thread.scrollTop = thread.scrollHeight; - } - - function setFoot(html) { - var foot = state.backdrop && state.backdrop.querySelector('[data-role="foot"]'); - if (foot) foot.innerHTML = html || ""; - } - - function openFilledIssue(result) { - if (!result || !result.issue_title) return; - var url = result.issue_url || (ISSUE_NEW + "&title=" + encodeURIComponent(result.issue_title) + "&body=" + encodeURIComponent(result.issue_body || result.answer || "")); - var body = result.issue_body || result.answer || ""; - if (navigator.clipboard && navigator.clipboard.writeText && body) { - navigator.clipboard.writeText(body).catch(function () {}); - } - window.open(url, "_blank", "noopener"); - } - - function parseSSEChunk(buf, onEvent) { - var parts = buf.split("\n\n"); - var rest = parts.pop() || ""; - parts.forEach(function (block) { - var line = block.split("\n").filter(function (l) { return l.indexOf("data:") === 0; }).map(function (l) { - return l.replace(/^data:\s?/, ""); - }).join(""); - if (!line) return; - var ev; - try { ev = JSON.parse(line); } catch (e) { return; } - onEvent(ev); - }); - return rest; - } - - function ask(question) { - var q = String(question || "").trim(); - if (!q || state.busy) return; - var gen = state.generation; - var run = newRun(); - state.busy = true; - addUser(q); - setFoot(""); - var askBtn = state.backdrop && state.backdrop.querySelector('[data-role="ask"]'); - var input = state.backdrop && state.backdrop.querySelector('[data-role="input"]'); - // The button becomes Stop while the model works. A free model can - // run for a minute; leaving no way out but closing the dialog threw - // the thread away. - if (askBtn) askBtn.textContent = "Stop"; - if (input) { input.value = ""; input.disabled = true; } - var payload = { question: q, history: state.turns.slice() }; - if (state.trigger) payload.trigger = state.trigger; - if (state.threadID) payload.thread_id = state.threadID; - var fetchOpts = { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, - body: JSON.stringify(payload), - }; - if (typeof AbortController === "function") { - state.abort = new AbortController(); - fetchOpts.signal = state.abort.signal; - } - apiFetch("/api/assistant/ask", fetchOpts) - .then(function (r) { - if (!stillOpen(gen)) return null; - var ctype = (r.headers.get("Content-Type") || ""); - if (ctype.indexOf("text/event-stream") >= 0 && r.body && r.body.getReader) { - var reader = r.body.getReader(); - var decoder = new TextDecoder(); - var buf = ""; - var donePayload = null; - function pump() { - return reader.read().then(function (chunk) { - if (!stillOpen(gen)) return null; - if (chunk.value) buf += decoder.decode(chunk.value, { stream: true }); - buf = parseSSEChunk(buf, function (ev) { - if (!stillOpen(gen)) return; - if (ev.type === "round") resetDraft(run); - if (ev.type === "status") addActivity(run, ev.text || "Working"); - if (ev.type === "tool") addActivity(run, toolLabel(ev.text), "tool"); - if (ev.type === "delta") { - run.streamed += ev.text || ""; - appendDelta(run); - } - if (ev.type === "error") throw new Error(ev.error || "Ask why failed"); - if (ev.type === "done") donePayload = ev; - }); - if (chunk.done) return donePayload; - return pump(); - }); - } - return pump().then(function (ev) { - if (!stillOpen(gen)) return null; - if (!ev) throw new Error("No answer"); - return ev; - }); - } - return r.json().then(function (j) { - if (!r.ok) throw new Error(j.error || ("HTTP " + r.status)); - return j; - }); - }) - .then(function (j) { - if (!stillOpen(gen) || !j) return; - settleActivity(run); - state.last = j; - if (j.thread_id) state.threadID = j.thread_id; - state.turns.push({ role: "user", text: q }); - state.turns.push({ role: "assistant", text: j.answer || "" }); - if (state.turns.length > 6) state.turns = state.turns.slice(-6); - finishAssistant(run, j.answer || ""); - var used = j.resolved_model || j.model || ""; - var foot = '' + escHtml(used ? "Answered by " + used : "Done") + ""; - if (j.issue_title) { - foot += ''; - } - setFoot(foot); - var issueBtn = state.backdrop && state.backdrop.querySelector('[data-role="open-issue"]'); - if (issueBtn) issueBtn.addEventListener("click", function () { openFilledIssue(state.last); }); - }) - .catch(function (err) { - if (!stillOpen(gen)) return; - settleActivity(run); - // Stopped by the operator: keep whatever arrived, as an answer - // that is simply cut short. - if (err && err.name === "AbortError") { - if (run.streamed.trim()) { - finishAssistant(run, visibleAnswer(run.streamed)); - addActivity(run, "Stopped"); - } else { - resetDraft(run); - addActivity(run, "Stopped before the model answered"); - } - return; - } - // A failed stream must not leave a half-written bubble behind: - // the next question would otherwise stream into it. - resetDraft(run); - var thread = threadEl(); - if (thread) { - var el = document.createElement("div"); - el.className = "ftw-ask-error"; - el.textContent = (err && err.message) || String(err); - thread.appendChild(el); - thread.scrollTop = thread.scrollHeight; - } - }) - .then(function () { - if (!stillOpen(gen)) return; - state.busy = false; - state.abort = null; - if (askBtn) { askBtn.disabled = false; askBtn.textContent = "Ask why"; } - if (input) { input.disabled = false; input.focus(); } - }); - } - - // "14:32" today, "Tue 14:32" this week, "3 Sep" beyond that. A stored - // conversation is found by when it happened, so the stamp has to say - // that without a full date on every row. - function whenLabel(ms) { - if (!ms) return ""; - var d = new Date(ms); - if (isNaN(d.getTime())) return ""; - var now = new Date(); - var hhmm = String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); - if (d.toDateString() === now.toDateString()) return hhmm; - if (now - d < 6 * 864e5) { - return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()] + " " + hhmm; - } - return d.getDate() + " " + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getMonth()]; - } - - function renderHistory(host) { - host.innerHTML = '

    Loading…

    '; - var gen = state.generation; - apiFetch("/api/assistant/threads") - .then(function (r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }) - .then(function (data) { - if (!stillOpen(gen)) return; - var threads = (data && data.threads) || []; - if (!threads.length) { - host.innerHTML = - '

    No earlier conversations yet. ' + - "Ask something and it is kept on the box.

    "; - return; - } - var rows = threads.map(function (t) { - return '
  • " + - '
  • '; - }).join(""); - host.innerHTML = '"; - host.querySelectorAll("[data-thread]").forEach(function (btn) { - btn.addEventListener("click", function () { openThread(btn.getAttribute("data-thread")); }); - }); - host.querySelectorAll("[data-del]").forEach(function (btn) { - btn.addEventListener("click", function () { - var id = btn.getAttribute("data-del"); - apiFetch("/api/assistant/threads/" + encodeURIComponent(id), { method: "DELETE" }) - .then(function () { if (stillOpen(gen)) renderHistory(host); }); - }); - }); - }) - .catch(function (err) { - if (!stillOpen(gen)) return; - host.innerHTML = '

    Could not read earlier conversations: ' + - escHtml((err && err.message) || String(err)) + "

    "; - }); - } - - // Reopen a stored conversation: its turns become the thread on screen and - // the composer continues it, so a follow-up lands in the same row. - function openThread(id) { - var gen = state.generation; - var body = state.backdrop && state.backdrop.querySelector('[data-role="body"]'); - if (!body) return; - apiFetch("/api/assistant/threads/" + encodeURIComponent(id)) - .then(function (r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }) - .then(function (t) { - if (!stillOpen(gen)) return; - state.threadID = t.id || ""; - state.turns = []; - showHistory(false); - renderReady(body, {}, {}); - (t.turns || []).forEach(function (turn) { - if (turn.role === "user") addUser(turn.text || ""); - else addAssistant(turn.text || ""); - state.turns.push({ role: turn.role, text: turn.text || "" }); - }); - if (state.turns.length > 6) state.turns = state.turns.slice(-6); - if (t.model) { - setFoot('' + escHtml("Answered by " + t.model) + ""); - } - }) - .catch(function (err) { - if (!stillOpen(gen)) return; - body.innerHTML = '

    Could not open that conversation: ' + - escHtml((err && err.message) || String(err)) + "

    "; - }); - } - - // Flip the header between the live conversation and the history list. - function showHistory(on) { - var root = state.backdrop; - if (!root) return; - var histBtn = root.querySelector('[data-role="history"]'); - var newBtn = root.querySelector('[data-role="new"]'); - if (histBtn) { - histBtn.setAttribute("aria-expanded", on ? "true" : "false"); - histBtn.hidden = !!on; - } - if (newBtn) newBtn.hidden = !on; - } - - function renderSetup(host, status) { - host.innerHTML = - '
    ' + - '

    ' + escHtml(status.unavailable || "Ask why needs an OpenRouter key.") + "

    " + - '

    Create a free key (no card) and paste it in Settings → System. Pasting the key turns Ask why on.

    ' + - '

    Get a free key

    ' + - "
    "; - } - - function renderReady(host, status, opts) { - opts = opts || {}; - var placeholder = (opts.trigger && opts.trigger.kind === "plan") - ? "Ask about this plan…" - : "Ask about this house…"; - host.innerHTML = - '
    ' + - '
    ' + - '
    ' + - ' ' + - ' ' + - "
    "; - var form = host.querySelector('[data-role="form"]'); - var input = host.querySelector('[data-role="input"]'); - form.addEventListener("submit", function (e) { - e.preventDefault(); - if (state.busy) { stop(); return; } - ask(input.value); - }); - if (opts.question) { - ask(opts.question); - } else { - input.focus(); - } - } - - function open(opts) { - opts = opts || {}; - ensureStyles(); - close(); - state.trigger = opts.trigger || null; - state.last = null; - state.turns = []; - state.threadID = ""; - var gen = state.generation; - var title = (opts.trigger && opts.trigger.kind === "plan") ? "Ask why this plan" : "Ask why"; - var backdrop = document.createElement("div"); - backdrop.className = "ftw-ask-backdrop"; - backdrop.innerHTML = - '"; - document.body.appendChild(backdrop); - state.backdrop = backdrop; - backdrop.addEventListener("click", function (e) { - if (e.target === backdrop) close(); - }); - backdrop.querySelector('[data-role="close"]').addEventListener("click", close); - backdrop.querySelector('[data-role="history"]').addEventListener("click", function () { - if (state.busy) return; - showHistory(true); - renderHistory(backdrop.querySelector('[data-role="body"]')); - }); - backdrop.querySelector('[data-role="new"]').addEventListener("click", function () { - state.threadID = ""; - state.turns = []; - state.last = null; - showHistory(false); - renderReady(backdrop.querySelector('[data-role="body"]'), {}, opts); - }); - state.keyHandler = function (e) { - if (e.key === "Escape") close(); - }; - document.addEventListener("keydown", state.keyHandler); - - var body = backdrop.querySelector('[data-role="body"]'); - apiFetch("/api/assistant/status") - .then(function (r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }) - .then(function (status) { - if (!stillOpen(gen)) return; - if (status.ready) renderReady(body, status, opts); - else renderSetup(body, status); - }) - .catch(function (err) { - if (!stillOpen(gen)) return; - body.innerHTML = '

    Could not load Ask why: ' + escHtml(err.message) + "

    "; - }); - } - - function askAboutPlan(question) { - var q = String(question || "").trim(); - if (!q) q = "Why did FTW plan the next hours this way?"; - open({ - question: q, - trigger: { kind: "plan" }, - }); - } - - function bind() { - var btn = document.getElementById("plan-ask-why"); - if (btn) btn.addEventListener("click", function () { open(); }); - var form = document.getElementById("plan-ask-why-form"); - var input = document.getElementById("plan-ask-why-input"); - if (form) { - form.addEventListener("submit", function (e) { - e.preventDefault(); - var q = input ? input.value : ""; - if (input) input.value = ""; - askAboutPlan(q); - }); - } - var chip = document.getElementById("ask-why-chip"); - if (chip) { - chip.addEventListener("click", function () { - var names = state.offlineNames || []; - var question = names.length === 1 - ? names[0] + " is offline. Why?" - : names.length + " drivers are offline. Why?"; - open({ - question: question, - trigger: { kind: "driver_offline", driver: names[0] || "" }, - }); - }); - } - window.addEventListener("ftw-status", function (e) { - updateChip(e.detail); - }); - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", bind); - } else { - bind(); - } - - window.FTWAskWhy = { open: open, close: close, updateChip: updateChip, _test: { parseSSEChunk: parseSSEChunk, visibleAnswer: visibleAnswer, formatAnswer: formatAnswer, whenLabel: whenLabel } }; -})(); diff --git a/web/assistant.test.mjs b/web/assistant.test.mjs deleted file mode 100644 index 5edb1f02d..000000000 --- a/web/assistant.test.mjs +++ /dev/null @@ -1,320 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import vm from "node:vm"; - -const assistant = readFileSync(new URL("./assistant.js", import.meta.url), "utf8"); -const index = readFileSync(new URL("./index.html", import.meta.url), "utf8"); -const system = readFileSync(new URL("./settings/tabs/system.js", import.meta.url), "utf8"); -const appJs = readFileSync(new URL("./app.js", import.meta.url), "utf8"); -const appCss = readFileSync(new URL("./app.css", import.meta.url), "utf8"); -const issueTemplate = readFileSync(new URL("../.github/ISSUE_TEMPLATE/ask_why.md", import.meta.url), "utf8"); -const goAssistant = readFileSync(new URL("../go/internal/assistant/assistant.go", import.meta.url), "utf8"); -const goApi = readFileSync(new URL("../go/internal/api/api_assistant.go", import.meta.url), "utf8"); -const goState = readFileSync(new URL("../go/internal/state/assistant_threads.go", import.meta.url), "utf8"); -const goSecurity = readFileSync(new URL("../go/internal/api/security.go", import.meta.url), "utf8"); -const goThreads = readFileSync(new URL("../go/internal/api/api_assistant_threads.go", import.meta.url), "utf8"); - -// escHtml escapes by writing textContent and reading innerHTML back, so -// the stub element has to do the same or the escaping tests prove nothing. -function stubElement() { - const el = { style: {}, innerHTML: "", setAttribute() {} }; - Object.defineProperty(el, "textContent", { - get: () => el.innerHTML, - set: (v) => { - el.innerHTML = String(v) - .replace(/&/g, "&") - .replace(//g, ">"); - }, - }); - return el; -} - -// Runs assistant.js against a stub DOM and hands back window.FTWAskWhy. -function sandboxed() { - const sandbox = { - document: { - readyState: "complete", - getElementById: () => null, - addEventListener() {}, - createElement: stubElement, - head: { appendChild() {} }, - body: { appendChild() {} }, - }, - fetch: () => new Promise(() => {}), - navigator: {}, - console, - addEventListener() {}, - }; - sandbox.window = sandbox; - vm.createContext(sandbox); - vm.runInContext(assistant, sandbox); - return sandbox.window.FTWAskWhy; -} - -test("Ask why is wired from the plan card", () => { - assert.match(index, /id="plan-ask-why"/); - assert.match(index, /assistant\.js/); - assert.match(assistant, /getElementById\("plan-ask-why"\)/); - assert.match(assistant, /\/api\/assistant\/ask/); - assert.match(assistant, /\/api\/assistant\/status/); -}); - -test("the plan briefing takes a question about the schedule", () => { - assert.match(index, /id="plan-ask-why-form"/); - assert.match(index, /id="plan-ask-why-plan"/); - assert.match(index, /id="plan-ask-why-input"/); - assert.match(assistant, /kind: "plan"/); - assert.match(assistant, /Why did FTW plan the next hours this way\?/); -}); - -test("Ask why drafts a GitHub issue instead of posting one", () => { - assert.match(assistant, /Open GitHub issue/); - assert.match(assistant, /issues\/new\?template=ask_why\.md/); - assert.doesNotMatch(assistant, /bug_report\.yml/); - assert.doesNotMatch(assistant, /api\.github\.com/); - assert.match(issueTemplate, /One text field/); - assert.doesNotMatch(issueTemplate, /What happened/); -}); - -test("the conversation keeps the question and the answer in separate messages", () => { - assert.match(assistant, /ftw-ask-msg user/); - assert.match(assistant, /ftw-ask-msg assistant/); - assert.match(assistant, /text\/event-stream/); - assert.match(assistant, /innerHTML = formatAnswer/); - assert.doesNotMatch(assistant, /innerHTML = j\.answer/); -}); - -test("the dialog keeps long answers inside the shell", () => { - assert.match(assistant, /ftw-ask-body/); - assert.match(assistant, /min-height:0/); - assert.match(assistant, /word-break:break-word/); -}); - -test("tokens and tool calls append while waiting", () => { - assert.match(assistant, /ev\.type === "delta"/); - assert.match(assistant, /ftw-ask-activity/); - assert.match(assistant, /ftw-ask-step/); - assert.match(assistant, /addActivity\(run, toolLabel/); -}); - -test("each question owns its activity log and draft bubble", () => { - // A DOM lookup would find the previous question's nodes and thread the - // new answer above the old one. - assert.doesNotMatch(assistant, /querySelector\('\[data-role="activity"\]'\)/); - assert.doesNotMatch(assistant, /querySelector\('\[data-role="draft"\]'\)/); - assert.match(assistant, /function newRun\(\)/); - assert.match(assistant, /run\.activity/); - assert.match(assistant, /run\.draft/); -}); - -test("a new model round drops the text that led to a tool call", () => { - assert.match(assistant, /ev\.type === "round"/); - assert.match(assistant, /function resetDraft\(run\)/); - assert.match(goAssistant, /Progress\("round", ""\)/); -}); - -test("a failed answer leaves no half-written bubble behind", () => { - // Without this the next question streams into the stale draft. - const catchBlock = assistant.slice(assistant.indexOf(".catch(function (err)")); - assert.match(catchBlock, /resetDraft\(run\)/); -}); - -test("the operator can stop a slow model without losing the thread", () => { - assert.match(assistant, /function stop\(\)/); - assert.match(assistant, /if \(state\.busy\) \{ stop\(\); return; \}/); - assert.match(assistant, /askBtn\.textContent = "Stop"/); - assert.match(assistant, /askBtn\.textContent = "Ask why"/); -}); - -test("the activity log says each step once", () => { - // The handler used to announce "Asking the model" that Complete also - // sends once per round. - assert.doesNotMatch(goApi, /progress\("status", "Asking the model"\)/); - assert.match(goAssistant, /Progress\("status", "Asking the model"\)/); -}); - -test("the answer renders markdown, not raw dashes and hashes", () => { - const md = sandboxed()._test.formatAnswer; - const html = md("## Answer\n\nCharging **now**.\n\n- SoC is 41 %\n- Grid limit is 11 kW\n\n1. Cheap slot\n2. Expensive later\n\n`sungrow` is fine."); - assert.match(html, /