From 8855091b30880b863bdfd91678de96e12fc490d5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 09:16:09 -0400 Subject: [PATCH 1/9] AI settings API with key management (#80) Co-authored-by: dev_agent --- cmd/server/main.go | 8 +- docs/ARCHITECTURE.md | 23 ++ internal/ai/aisettings/aisettings.go | 478 ++++++++++++++++++++++ internal/ai/aisettings/aisettings_test.go | 463 +++++++++++++++++++++ internal/ai/llm/presets/presets.go | 40 +- internal/ai/llm/presets/presets_test.go | 33 ++ internal/household/defaults.go | 3 +- internal/server/aisettings.go | 145 +++++++ internal/server/aisettings_test.go | 261 ++++++++++++ internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/recipesearch_test.go | 2 +- internal/server/server.go | 16 +- internal/server/server_test.go | 55 ++- internal/server/settings.go | 18 +- internal/server/settings_test.go | 2 +- 17 files changed, 1523 insertions(+), 30 deletions(-) create mode 100644 internal/ai/aisettings/aisettings.go create mode 100644 internal/ai/aisettings/aisettings_test.go create mode 100644 internal/server/aisettings.go create mode 100644 internal/server/aisettings_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 01c4ee5..c23b5db 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "syscall" "time" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" @@ -83,6 +84,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + ai, err := aisettings.New(db, box, cfg.PublicURL.String()) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -99,7 +105,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fbbfdd5..c6e0e82 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -152,6 +152,29 @@ automatic** (D-021). `POST /api/v1/settings/recipe-search/test` searches "chicken tikka masala" and returns `{query, count}`, 409 `not_configured`, or 502 `search_failed` with the provider's error. +- `GET`/`PATCH /api/v1/settings/ai` (`aisettings.Service`) — provider, + base_url, model, temperature, system_prompt, plus the read-only + `api_key_last4` and `key_verified_at`. PATCH requires a preset name as + provider, temperature 0–1 in 0.1 steps, a non-empty model and a + system_prompt of at most 8000 characters; a fixed-URL preset's base_url + must be empty or its own. Provider and base_url cannot change while a + key is stored (remove the key first), so a stored key is never sent to + an endpoint it was not stored for. + `PUT /api/v1/settings/ai/api-key` takes `{api_key}` (write-only, at least + 8 characters, sealed with the secret box; an empty string removes it) and + clears `key_verified_at`; a preset without a fixed base_url takes a key + only when its base_url passes `secureurl.Check`. `POST /api/v1/settings/ai/verify` calls + `Provider.Verify` and records `key_verified_at`; + `GET /api/v1/settings/ai/models` returns `{models: [{id, display_name}]}` + live from the provider, with the key redacted from every id and + display_name. Both work before a model is chosen + (`presets.ForAccount`), answer 409 `not_configured` when the settings + cannot build a provider, and 502 with the provider's typed error + (`auth_failed`, `rate_limited`, `bad_output`, `provider_error`; 504 + `timeout`) when the call fails. `POST /api/v1/settings/ai/reset-prompt` + restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No + response holds the key. `Service.HarnessConfig` reads the settings for + each skill run, so a saved change applies to the next run. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. diff --git a/internal/ai/aisettings/aisettings.go b/internal/ai/aisettings/aisettings.go new file mode 100644 index 0000000..cfc7dcd --- /dev/null +++ b/internal/ai/aisettings/aisettings.go @@ -0,0 +1,478 @@ +// Package aisettings reads and changes the household's AI settings — the +// ai_settings row seeded by household.SeedAISettings — and builds the +// provider and harness.Config they describe. The API key is sealed with the +// secret box and never leaves the package except to the provider. +package aisettings + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "math" + "net/url" + "strings" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const ( + // MaxSystemPromptChars is the longest system prompt accepted, in + // characters. + MaxSystemPromptChars = 8000 + // MinKeyLength is the shortest API key accepted, so its last four + // characters never give away most of it. + MinKeyLength = 8 +) + +var ( + // ErrNotConfigured is returned when the stored AI settings cannot + // build a provider, e.g. there is no API key yet. It is joined with + // the llm configuration error that says why. + ErrNotConfigured = errors.New("aisettings: AI provider is not configured") + // ErrProviderFailed is returned when a call to the provider fails. It + // is joined with the provider's error, so the llm typed error it + // matches says why. + ErrProviderFailed = errors.New("aisettings: AI provider call failed") + // ErrChanged is returned when the AI settings changed while + // their key was being verified, so the result applies to old settings. + ErrChanged = errors.New("aisettings: AI settings changed during verification") +) + +// Settings is the household's AI configuration as the API shows it. The +// API key is never part of it: only its last four characters. +type Settings struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + Temperature float64 `json:"temperature"` + // SystemPrompt is the admin persona, nil when none is stored. + SystemPrompt *string `json:"system_prompt"` + // APIKeyLast4 is nil when no key is stored. + APIKeyLast4 *string `json:"api_key_last4"` + // KeyVerifiedAt is when the stored key was last verified with these + // provider settings, nil when it has not been. + KeyVerifiedAt *string `json:"key_verified_at"` + UpdatedAt string `json:"updated_at"` +} + +// Patch changes the settings. A nil field is left as it is. +type Patch struct { + Provider *string `json:"provider"` + BaseURL *string `json:"base_url"` + Model *string `json:"model"` + Temperature *float64 `json:"temperature"` + SystemPrompt *string `json:"system_prompt"` +} + +// Model is one model the provider offers. +type Model struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` +} + +// Problem is one invalid field, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every problem with an Patch or API +// key, or with the settings it would leave. No problem quotes the key. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid AI settings: " + strings.Join(parts, "; ") +} + +// Service reads and changes the ai_settings row and builds the provider it +// names. API keys are sealed with its secret box and never leave it except +// to the provider. +type Service struct { + st *store.Store + box *secretbox.Box + publicURL string + // newProvider and newAccount are presets.FromSettings and + // presets.ForAccount; tests substitute a fake provider. + newProvider harness.NewProvider + newAccount func(llm.Settings, string) (presets.Account, error) +} + +// New returns the service over st, sealing keys with box. +// publicURL is MEALQUEST_PUBLIC_URL, which some presets send. +func New(st *store.Store, box *secretbox.Box, publicURL string) (*Service, error) { + switch { + case st == nil: + return nil, errors.New("aisettings: nil store") + case box == nil: + return nil, errors.New("aisettings: nil secret box") + case publicURL == "": + return nil, errors.New("aisettings: empty public URL") + } + return &Service{st: st, box: box, publicURL: publicURL, newProvider: presets.FromSettings, newAccount: presets.ForAccount}, nil +} + +// Get returns the settings. +func (s *Service) Get(ctx context.Context) (Settings, error) { + row, err := s.read(ctx, db.New(s.st.Reader)) + if err != nil { + return Settings{}, err + } + return settingsFrom(row), nil +} + +// Update applies patch and returns the settings it leaves. The provider +// and base URL cannot change while an API key is stored, so the key never +// reaches an endpoint it was not stored for. Every problem is reported in +// a *ValidationError and nothing changes. +func (s *Service) Update(ctx context.Context, patch Patch) (Settings, error) { + return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) { + return applyPatch(row, patch) + }) +} + +// SetAPIKey seals key and records its last four characters, clearing +// key_verified_at. An empty key removes the stored one. A preset without +// a fixed base URL takes a key only when its base URL is secure (see +// secureurl.Check). +func (s *Service) SetAPIKey(ctx context.Context, key string) (Settings, error) { + return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) { + row.ApiKeyCt, row.ApiKeyLast4, row.KeyVerifiedAt = nil, sql.NullString{}, sql.NullString{} + if key == "" { + return row, nil + } + p, _ := presets.Lookup(row.Provider) + var reason string + switch { + case p.Key == presets.KeyNone: + reason = row.Provider + " takes no API key" + case p.BaseURL == "" && !secureBaseURL(row.BaseUrl): + reason = "is sent only over https://, or http:// to localhost: set such a base_url first" + case !validHeaderValue(key): + reason = "has a character not allowed in a header" + case len(key) < MinKeyLength: + reason = fmt.Sprintf("must be at least %d characters", MinKeyLength) + } + if reason != "" { + return db.AiSetting{}, &ValidationError{Problems: []Problem{{Field: "api_key", Reason: reason}}} + } + ct, err := s.box.Encrypt([]byte(key)) + if err != nil { + return db.AiSetting{}, fmt.Errorf("aisettings: seal AI API key: %w", err) + } + row.ApiKeyCt = ct + row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true} + return row, nil + }) +} + +// ResetPrompt restores household.DefaultPersonaPrompt as the system prompt. +func (s *Service) ResetPrompt(ctx context.Context) (Settings, error) { + return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) { + row.SystemPrompt = sql.NullString{String: household.DefaultPersonaPrompt, Valid: true} + return row, nil + }) +} + +// Verify checks the stored key with the provider and, when it is +// accepted, records key_verified_at. Settings that cannot build a +// provider fail with ErrNotConfigured; a provider failure matches +// ErrProviderFailed and the llm typed error the provider returned. If the +// settings change while the provider is asked, nothing is recorded and the +// error is ErrChanged. +func (s *Service) Verify(ctx context.Context) (Settings, error) { + row, err := s.read(ctx, db.New(s.st.Reader)) + if err != nil { + return Settings{}, err + } + acct, key, err := s.account(row) + if err != nil { + return Settings{}, err + } + if err := acct.Verify(ctx); err != nil { + return Settings{}, redact(fmt.Errorf("%w: verify: %w", ErrProviderFailed, err), key) + } + verified := store.Now().String() + return s.change(ctx, func(now db.AiSetting) (db.AiSetting, error) { + if now.Provider != row.Provider || now.BaseUrl != row.BaseUrl || !bytes.Equal(now.ApiKeyCt, row.ApiKeyCt) { + return db.AiSetting{}, ErrChanged + } + now.KeyVerifiedAt = sql.NullString{String: verified, Valid: true} + return now, nil + }) +} + +// Models returns the provider's live model list with the API key removed +// from every model's ID and display name. Its errors are Verify's. +func (s *Service) Models(ctx context.Context) ([]Model, error) { + row, err := s.read(ctx, db.New(s.st.Reader)) + if err != nil { + return nil, err + } + acct, key, err := s.account(row) + if err != nil { + return nil, err + } + models, err := acct.ListModels(ctx) + if err != nil { + return nil, redact(fmt.Errorf("%w: list models: %w", ErrProviderFailed, err), key) + } + out := make([]Model, len(models)) + for i, m := range models { + out[i] = Model{ID: llm.Redact(m.ID, key), DisplayName: llm.Redact(m.DisplayName, key)} + } + return out, nil +} + +// HarnessConfig returns the harness.Config for a skill run with the +// current settings, sharing guard. Read it for each run, so a run always +// uses the latest saved provider, model, temperature and persona. +func (s *Service) HarnessConfig(ctx context.Context, guard *harness.Guard) (harness.Config, error) { + row, err := s.read(ctx, db.New(s.st.Reader)) + if err != nil { + return harness.Config{}, err + } + key, err := s.key(row) + if err != nil { + return harness.Config{}, err + } + return harness.Config{ + Settings: s.llmSettings(row), + APIKey: key, + NewProvider: s.newProvider, + Persona: row.SystemPrompt.String, + Store: s.st, + Guard: guard, + }, nil +} + +// account builds the provider's Account from row, returning the key too +// so the caller can redact it. +func (s *Service) account(row db.AiSetting) (presets.Account, string, error) { + key, err := s.key(row) + if err != nil { + return nil, "", err + } + acct, err := s.newAccount(s.llmSettings(row), key) + if err != nil { + return nil, "", redact(fmt.Errorf("%w: %w", ErrNotConfigured, err), key) + } + return acct, key, nil +} + +func (s *Service) llmSettings(row db.AiSetting) llm.Settings { + return llm.Settings{ + Provider: row.Provider, + BaseURL: row.BaseUrl, + Model: row.Model, + Temperature: row.Temperature, + PublicURL: s.publicURL, + } +} + +// key opens row's API key; "" when none is stored. +func (s *Service) key(row db.AiSetting) (string, error) { + if len(row.ApiKeyCt) == 0 { + return "", nil + } + key, err := s.box.Decrypt(row.ApiKeyCt) + if err != nil { + return "", fmt.Errorf("aisettings: open AI API key: %w", err) + } + return string(key), nil +} + +func (s *Service) read(ctx context.Context, q *db.Queries) (db.AiSetting, error) { + row, err := q.GetAISettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return db.AiSetting{}, errors.New("aisettings: AI settings are not seeded") + } + if err != nil { + return db.AiSetting{}, fmt.Errorf("aisettings: read AI settings: %w", err) + } + return row, nil +} + +// change saves edit's result for the current row in one transaction. +func (s *Service) change(ctx context.Context, edit func(db.AiSetting) (db.AiSetting, error)) (Settings, error) { + var saved db.AiSetting + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + row, err := s.read(ctx, q) + if err != nil { + return err + } + next, err := edit(row) + if err != nil { + return err + } + saved, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{ + Provider: next.Provider, + BaseUrl: next.BaseUrl, + Model: next.Model, + Temperature: next.Temperature, + SystemPrompt: next.SystemPrompt, + ApiKeyCt: next.ApiKeyCt, + ApiKeyLast4: next.ApiKeyLast4, + KeyVerifiedAt: next.KeyVerifiedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: store.Now().String(), + }) + if err != nil { + return fmt.Errorf("aisettings: save AI settings: %w", err) + } + return nil + }) + if err != nil { + return Settings{}, err + } + return settingsFrom(saved), nil +} + +// applyPatch returns row with patch applied, validated. +func applyPatch(row db.AiSetting, patch Patch) (db.AiSetting, error) { + var problems []Problem + fail := func(field, reason string) { + problems = append(problems, Problem{Field: field, Reason: reason}) + } + if patch == (Patch{}) { + fail("settings", "no settings to change") + } + before := row + if patch.Provider != nil { + if _, ok := presets.Lookup(*patch.Provider); ok { + row.Provider = *patch.Provider + } else { + fail("provider", "must be one of "+strings.Join(presets.Names(), ", ")) + } + } + if patch.BaseURL != nil { + row.BaseUrl = *patch.BaseURL + } + if patch.Model != nil { + switch m := *patch.Model; { + case strings.TrimSpace(m) == "": + fail("model", "must not be empty") + case !validHeaderValue(m): + fail("model", "must not contain control characters") + default: + row.Model = m + } + } + if patch.Temperature != nil { + t := *patch.Temperature + tenths := math.Round(t * 10) + if t < 0 || t > 1 || math.Abs(t*10-tenths) > 1e-9 { + fail("temperature", "must be between 0 and 1 in steps of 0.1") + } else { + row.Temperature = tenths / 10 + } + } + if patch.SystemPrompt != nil { + if n := utf8.RuneCountInString(*patch.SystemPrompt); n > MaxSystemPromptChars { + fail("system_prompt", fmt.Sprintf("must be at most %d characters, got %d", MaxSystemPromptChars, n)) + } else { + row.SystemPrompt = sql.NullString{String: *patch.SystemPrompt, Valid: true} + } + } + if p, ok := presets.Lookup(row.Provider); ok && len(problems) == 0 { + switch { + case p.BaseURL != "" && row.BaseUrl != "" && row.BaseUrl != p.BaseURL: + fail("base_url", fmt.Sprintf("is fixed for %s: must be empty or %s", p.Name, p.BaseURL)) + case p.BaseURL == "" && row.BaseUrl != "": + // The preset's own check of the URL; its errors never quote it. + if _, err := presets.ForAccount(llm.Settings{Provider: p.Name, BaseURL: row.BaseUrl}, ""); err != nil { + fail("base_url", err.Error()) + } + } + } + // The stored key is only ever sent where it was stored for: moving it + // to another provider or server would let a changed base URL receive + // it. Remove the key, change the endpoint, then store the new key. + if len(problems) == 0 && len(row.ApiKeyCt) > 0 { + if row.Provider != before.Provider { + fail("provider", "cannot change while an API key is stored: remove the key first") + } + if row.BaseUrl != before.BaseUrl { + fail("base_url", "cannot change while an API key is stored: remove the key first") + } + } + if len(problems) > 0 { + return db.AiSetting{}, &ValidationError{Problems: problems} + } + return row, nil +} + +func settingsFrom(row db.AiSetting) Settings { + return Settings{ + Provider: row.Provider, + BaseURL: row.BaseUrl, + Model: row.Model, + Temperature: row.Temperature, + SystemPrompt: nullString(row.SystemPrompt), + APIKeyLast4: nullString(row.ApiKeyLast4), + KeyVerifiedAt: nullString(row.KeyVerifiedAt), + UpdatedAt: row.UpdatedAt, + } +} + +func nullString(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} + +// secureBaseURL reports whether base is a URL a key may be sent to. +func secureBaseURL(base string) bool { + u, err := url.Parse(base) + return base != "" && err == nil && secureurl.Check(u) == nil +} + +// validHeaderValue reports whether value has no control character other +// than tab. +func validHeaderValue(value string) bool { + for i := 0; i < len(value); i++ { + if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f { + return false + } + } + return true +} + +// redactedError is an error with the API key removed from its text. It +// does not unwrap, because the original may hold the key; Is still matches +// the sentinels the original wraps. +type redactedError struct { + text string + err error +} + +func (e *redactedError) Error() string { return e.text } + +// Is reports whether the original error matches target. +func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) } + +// redact returns err with key removed from its text; an empty key returns +// err unchanged. +func redact(err error, key string) error { + if key == "" { + return err + } + return &redactedError{text: llm.Redact(err.Error(), key), err: err} +} diff --git a/internal/ai/aisettings/aisettings_test.go b/internal/ai/aisettings/aisettings_test.go new file mode 100644 index 0000000..3bf9fe8 --- /dev/null +++ b/internal/ai/aisettings/aisettings_test.go @@ -0,0 +1,463 @@ +package aisettings + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const testKey = "sk-ant-test-secret-key-41ab" + +// newService returns a Service over a migrated store with the seed AI +// settings. +func newService(t *testing.T) (*Service, *store.Store) { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedAISettings(ctx, st); err != nil { + t.Fatal(err) + } + raw := make([]byte, secretbox.KeySize) + rand.Read(raw) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(raw)) + if err != nil { + t.Fatal(err) + } + s, err := New(st, box, "https://mealquest.example") + if err != nil { + t.Fatal(err) + } + return s, st +} + +// useFake makes s build f for every run, verification and model list, and +// returns a func reporting the settings and key each build was given. +func useFake(s *Service, f *llm.Fake) func() ([]llm.Settings, []string) { + var mu sync.Mutex + var settings []llm.Settings + var keys []string + record := func(set llm.Settings, key string) { + mu.Lock() + defer mu.Unlock() + settings = append(settings, set) + keys = append(keys, key) + } + s.newProvider = func(set llm.Settings, key string) (llm.Provider, error) { + record(set, key) + return f, nil + } + s.newAccount = func(set llm.Settings, key string) (presets.Account, error) { + record(set, key) + return f, nil + } + return func() ([]llm.Settings, []string) { + mu.Lock() + defer mu.Unlock() + return settings, keys + } +} + +func ptr[T any](v T) *T { return &v } + +// echoSkill asks the model to repeat a word; its prompt is the harness's +// test template prompts/echo.v1.tmpl. +type echoSkill struct{} + +type echoInput struct{ Word string } + +type echoOutput struct { + Echo string `json:"echo"` +} + +func (echoSkill) Name() string { return "echo" } +func (echoSkill) Version() int { return 1 } +func (echoSkill) PromptInput(in echoInput) (harness.PromptInput, error) { + return harness.PromptInput{Data: in}, nil +} +func (echoSkill) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "echo", Schema: json.RawMessage( + `{"type":"object","properties":{"echo":{"type":"string"}},"required":["echo"],"additionalProperties":false}`)} +} +func (echoSkill) Validate(echoOutput) []string { return nil } +func (echoSkill) PostProcess(out echoOutput) (echoOutput, error) { return out, nil } +func (echoSkill) Timeout() time.Duration { return harness.DefaultTimeout } + +// A PATCHed model and temperature are what the next skill run uses: the +// provider is built with them, the request carries the temperature and the +// ai_runs row records both. A second PATCH changes the run after it. +func TestPatchedModelAndTemperatureUsedByNextRun(t *testing.T) { + ctx := context.Background() + s, st := newService(t) + f := &llm.Fake{} + built := useFake(s, f) + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + t.Fatal(err) + } + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + + for i, want := range []struct { + model string + temp float64 + }{{"claude-patched", 0.3}, {"claude-repatched", 1}} { + if _, err := s.Update(ctx, Patch{Model: ptr(want.model), Temperature: ptr(want.temp)}); err != nil { + t.Fatal(err) + } + cfg, err := s.HarnessConfig(ctx, guard) + if err != nil { + t.Fatal(err) + } + f.QueueGenerate(llm.GenerateResult{Response: llm.Response{JSON: json.RawMessage(`{"echo":"hi"}`)}}) + if _, err := harness.Run(ctx, cfg, echoSkill{}, "test", echoInput{Word: "hi"}); err != nil { + t.Fatalf("run %d: %v", i, err) + } + + settings, keys := built() + if got := settings[i]; got.Provider != "anthropic" || got.Model != want.model || got.Temperature != want.temp || keys[i] != testKey { + t.Errorf("run %d provider built with %+v, key ok %v; want model %s at %v", i, got, keys[i] == testKey, want.model, want.temp) + } + req := f.Requests()[i] + if req.Temperature == nil || *req.Temperature != want.temp { + t.Errorf("run %d request temperature = %v, want %v", i, req.Temperature, want.temp) + } + if !strings.HasPrefix(req.System, household.DefaultPersonaPrompt[:40]) { + t.Errorf("run %d system prompt does not start with the persona: %.80q", i, req.System) + } + var model string + var temp float64 + err = st.Reader.QueryRowContext(ctx, `SELECT model, temperature FROM ai_runs ORDER BY created_at DESC, rowid DESC LIMIT 1`).Scan(&model, &temp) + if err != nil { + t.Fatal(err) + } + if model != want.model || temp != want.temp { + t.Errorf("run %d ai_runs row = %s at %v, want %s at %v", i, model, temp, want.model, want.temp) + } + } +} + +func TestUpdateValidates(t *testing.T) { + ctx := context.Background() + for _, c := range []struct { + name string + patch Patch + field string + }{ + {"empty", Patch{}, "settings"}, + {"unknown provider", Patch{Provider: ptr("gemini")}, "provider"}, + {"temperature above 1", Patch{Temperature: ptr(1.1)}, "temperature"}, + {"negative temperature", Patch{Temperature: ptr(-0.1)}, "temperature"}, + {"temperature between steps", Patch{Temperature: ptr(0.35)}, "temperature"}, + {"empty model", Patch{Model: ptr(" ")}, "model"}, + {"long system prompt", Patch{SystemPrompt: ptr(strings.Repeat("ü", MaxSystemPromptChars+1))}, "system_prompt"}, + {"other base URL for a fixed preset", Patch{BaseURL: ptr("https://api.example/v1")}, "base_url"}, + {"bad base URL", Patch{Provider: ptr("ollama"), BaseURL: ptr("ftp://ollama:11434")}, "base_url"}, + } { + t.Run(c.name, func(t *testing.T) { + s, _ := newService(t) + before, err := s.Get(ctx) + if err != nil { + t.Fatal(err) + } + _, err = s.Update(ctx, c.patch) + var invalid *ValidationError + if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != c.field { + t.Fatalf("Update = %v, want one %s problem", err, c.field) + } + if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) { + t.Errorf("settings changed by a refused patch: %+v, %v", after, err) + } + }) + } +} + +func TestUpdateAccepts(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + for _, temp := range []float64{0, 0.1, 0.2, 0.3, 0.6, 0.7, 0.9, 1} { + got, err := s.Update(ctx, Patch{Temperature: ptr(temp)}) + if err != nil || got.Temperature != temp { + t.Errorf("temperature %v = %v, %v", temp, got.Temperature, err) + } + } + prompt := strings.Repeat("ü", MaxSystemPromptChars) + if got, err := s.Update(ctx, Patch{SystemPrompt: &prompt}); err != nil || *got.SystemPrompt != prompt { + t.Errorf("system prompt of %d characters: %v", MaxSystemPromptChars, err) + } + for _, p := range presets.Names() { + patch := Patch{Provider: ptr(p), BaseURL: ptr("")} + if pr, _ := presets.Lookup(p); pr.BaseURL == "" { + patch.BaseURL = ptr("http://llm.local:11434") + } + if got, err := s.Update(ctx, patch); err != nil || got.Provider != p { + t.Errorf("provider %s: %+v, %v", p, got, err) + } + } +} + +// While a key is stored the provider and base URL cannot change, so the +// key never reaches an endpoint it was not stored for; the model, +// temperature and system prompt can, keeping the verification. +func TestEndpointLockedWhileKeyStored(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + f := &llm.Fake{} + useFake(s, f) + if _, err := s.Update(ctx, Patch{Provider: ptr("openai_compatible"), BaseURL: ptr("https://llm.example/v1")}); err != nil { + t.Fatal(err) + } + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + f.QueueVerify(nil) + if got, err := s.Verify(ctx); err != nil || got.KeyVerifiedAt == nil { + t.Fatalf("Verify = %+v, %v", got, err) + } + got, err := s.Update(ctx, Patch{Model: ptr("m"), Temperature: ptr(0.5), SystemPrompt: ptr("p")}) + if err != nil || got.KeyVerifiedAt == nil { + t.Errorf("model change = %+v, %v; want verification kept", got, err) + } + before, err := s.Get(ctx) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + patch Patch + field string + }{ + {Patch{BaseURL: ptr("https://attacker.example/v1")}, "base_url"}, + {Patch{Provider: ptr("openai"), BaseURL: ptr("")}, "provider"}, + {Patch{Provider: ptr("ollama"), BaseURL: ptr("http://localhost:11434")}, "provider"}, + } { + var invalid *ValidationError + _, err := s.Update(ctx, c.patch) + if !errors.As(err, &invalid) || !slices.ContainsFunc(invalid.Problems, func(p Problem) bool { return p.Field == c.field }) { + t.Errorf("Update(%+v) with a key stored = %v, want a %s problem", c.patch, err, c.field) + } + } + if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) { + t.Errorf("refused endpoint change altered the settings: %+v, %v", after, err) + } + + // Removing the key frees the endpoint; a new key is unverified. + if _, err := s.SetAPIKey(ctx, ""); err != nil { + t.Fatal(err) + } + if _, err := s.Update(ctx, Patch{Provider: ptr("openai"), BaseURL: ptr("")}); err != nil { + t.Fatal(err) + } + got, err = s.SetAPIKey(ctx, "sk-another-key-9999") + if err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "9999" { + t.Errorf("new key = %+v, %v; want it unverified", got, err) + } +} + +// A key is never stored for a base URL it would cross the network to in +// plain text. +func TestSetAPIKeyNeedsSecureBaseURL(t *testing.T) { + ctx := context.Background() + for _, c := range []struct { + base string + ok bool + }{ + {"", false}, + {"http://llm.lan:8080/v1", false}, + {"http://127.0.0.1:8080/v1", true}, + {"https://llm.example/v1", true}, + } { + s, _ := newService(t) + patch := Patch{Provider: ptr("openai_compatible")} + if c.base != "" { + patch.BaseURL = ptr(c.base) + } + if _, err := s.Update(ctx, patch); err != nil { + t.Fatal(err) + } + _, err := s.SetAPIKey(ctx, testKey) + var invalid *ValidationError + if c.ok != (err == nil) || (!c.ok && !errors.As(err, &invalid)) { + t.Errorf("SetAPIKey with base URL %q = %v, want ok %v", c.base, err, c.ok) + } + } +} + +func TestVerifyReturnsProviderTypedError(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + f := &llm.Fake{} + useFake(s, f) + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + for _, want := range []error{llm.ErrAuth, llm.NewRateLimitedError(time.Second, testKey), llm.NewProviderError(500, []byte("bad "+testKey), testKey)} { + f.QueueVerify(want) + _, err := s.Verify(ctx) + if !errors.Is(err, ErrProviderFailed) || !errors.Is(err, want) { + t.Errorf("Verify = %v, want ErrProviderFailed and %v", err, want) + } + if err != nil && strings.Contains(err.Error(), testKey) { + t.Errorf("Verify error holds the key: %v", err) + } + } + if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil { + t.Errorf("failed verification recorded: %+v, %v", got, err) + } +} + +// A key error that quotes the key, as a misbehaving adapter might, is +// redacted by the service. +func TestVerifyRedactsKey(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + f := &llm.Fake{} + useFake(s, f) + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + f.QueueVerify(errors.Join(llm.ErrAuth, errors.New("rejected "+testKey))) + _, err := s.Verify(ctx) + if !errors.Is(err, llm.ErrAuth) || strings.Contains(err.Error(), testKey) { + t.Errorf("Verify = %v, want ErrAuth without the key", err) + } + f.QueueListModels(llm.ListModelsResult{Err: errors.New("rejected " + testKey)}) + if _, err := s.Models(ctx); !errors.Is(err, ErrProviderFailed) || strings.Contains(err.Error(), testKey) { + t.Errorf("Models = %v, want ErrProviderFailed without the key", err) + } + f.QueueListModels(llm.ListModelsResult{Models: []llm.Model{{ID: "m-" + testKey, DisplayName: "Model " + testKey}}}) + models, err := s.Models(ctx) + if err != nil || len(models) != 1 || models[0] != (Model{ID: "m-[REDACTED]", DisplayName: "Model [REDACTED]"}) { + t.Errorf("Models = %+v, %v, want the key redacted", models, err) + } +} + +// Settings changed while the provider verifies the key are not marked +// verified. +func TestVerifyRefusesChangedSettings(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + if _, err := s.SetAPIKey(ctx, testKey); err != nil { + t.Fatal(err) + } + s.newAccount = func(llm.Settings, string) (presets.Account, error) { + return changingAccount{s: s}, nil + } + if _, err := s.Verify(ctx); !errors.Is(err, ErrChanged) { + t.Fatalf("Verify = %v, want ErrChanged", err) + } + if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "5678" { + t.Errorf("settings after a changed verification = %+v, %v", got, err) + } +} + +// changingAccount replaces the stored key while it verifies. +type changingAccount struct{ s *Service } + +func (a changingAccount) Verify(ctx context.Context) error { + _, err := a.s.SetAPIKey(ctx, "sk-replaced-5678") + return err +} + +func (changingAccount) ListModels(context.Context) ([]llm.Model, error) { return nil, nil } + +func TestNotConfigured(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + // The seed is anthropic with no key; the real presets refuse it. + if _, err := s.Verify(ctx); !errors.Is(err, ErrNotConfigured) || !errors.Is(err, llm.ErrMissingKey) { + t.Errorf("Verify = %v, want ErrNotConfigured and ErrMissingKey", err) + } + if _, err := s.Models(ctx); !errors.Is(err, ErrNotConfigured) { + t.Errorf("Models = %v, want ErrNotConfigured", err) + } +} + +func TestSetAPIKey(t *testing.T) { + ctx := context.Background() + s, st := newService(t) + got, err := s.SetAPIKey(ctx, testKey) + if err != nil || got.APIKeyLast4 == nil || *got.APIKeyLast4 != "41ab" { + t.Fatalf("SetAPIKey = %+v, %v", got, err) + } + var ct []byte + if err := st.Reader.QueryRowContext(ctx, `SELECT api_key_ct FROM ai_settings`).Scan(&ct); err != nil { + t.Fatal(err) + } + if strings.Contains(string(ct), testKey) { + t.Error("the key is stored in plain text") + } + if key, err := s.key(mustRow(t, s)); err != nil || key != testKey { + t.Errorf("stored key opens to %q, %v", key, err) + } + + for _, bad := range []string{"short", "sk-bad\r\nkey"} { + var invalid *ValidationError + if _, err := s.SetAPIKey(ctx, bad); !errors.As(err, &invalid) { + t.Errorf("SetAPIKey(%q) = %v, want a ValidationError", bad, err) + } + } + + // Ollama takes no key: neither storing one nor switching to it with one. + var invalid *ValidationError + if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); !errors.As(err, &invalid) { + t.Errorf("switch to ollama with a key = %v, want a ValidationError", err) + } + if got, err := s.SetAPIKey(ctx, ""); err != nil || got.APIKeyLast4 != nil { + t.Fatalf("remove key = %+v, %v", got, err) + } + if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); err != nil { + t.Fatal(err) + } + if _, err := s.SetAPIKey(ctx, testKey); !errors.As(err, &invalid) { + t.Errorf("SetAPIKey for ollama = %v, want a ValidationError", err) + } +} + +func TestResetPrompt(t *testing.T) { + ctx := context.Background() + s, _ := newService(t) + if _, err := s.Update(ctx, Patch{SystemPrompt: ptr("")}); err != nil { + t.Fatal(err) + } + got, err := s.ResetPrompt(ctx) + if err != nil || got.SystemPrompt == nil || *got.SystemPrompt != household.DefaultPersonaPrompt { + t.Errorf("ResetPrompt = %+v, %v", got, err) + } +} + +func mustRow(t *testing.T, s *Service) db.AiSetting { + t.Helper() + row, err := s.read(context.Background(), db.New(s.st.Reader)) + if err != nil { + t.Fatal(err) + } + return row +} diff --git a/internal/ai/llm/presets/presets.go b/internal/ai/llm/presets/presets.go index da86eda..26c7cdb 100644 --- a/internal/ai/llm/presets/presets.go +++ b/internal/ai/llm/presets/presets.go @@ -15,6 +15,7 @@ import ( "context" "errors" "fmt" + "maps" "math" "regexp" "slices" @@ -115,6 +116,11 @@ func Lookup(name string) (Preset, bool) { return p, ok } +// Names returns every preset's name, sorted. +func Names() []string { + return slices.Sorted(maps.Keys(all)) +} + // all is every preset, by Settings.Provider name. var all = map[string]Preset{ "anthropic": { @@ -181,19 +187,40 @@ var all = map[string]Preset{ // preset's OmitTemperature matches. It is wrapped in llm.NewRetrying, so // Generate retries transient vendor failures and has the default timeout. func FromSettings(s llm.Settings, key string) (llm.Provider, error) { - cp, err := fromSettings(s, key) + cp, err := fromSettings(s, key, true) if err != nil { return nil, redact(err, key) } return llm.NewRetrying(cp), nil } -func fromSettings(s llm.Settings, key string) (*configured, error) { +// Account is what a provider offers before a model is chosen: the live +// model list and a check of the credentials. +type Account interface { + ListModels(ctx context.Context) ([]llm.Model, error) + Verify(ctx context.Context) error +} + +// ForAccount returns the Account s describes, using key as its API key. It +// checks s and key as FromSettings does except that s.Model may be empty, +// so the household can verify its key and pick from the live model list +// before it has chosen a model. Neither call is retried. +func ForAccount(s llm.Settings, key string) (Account, error) { + cp, err := fromSettings(s, key, false) + if err != nil { + return nil, redact(err, key) + } + return cp, nil +} + +// fromSettings builds the provider s describes; needModel is whether +// s.Model is required. +func fromSettings(s llm.Settings, key string, needModel bool) (*configured, error) { p, ok := Lookup(s.Provider) if !ok { return nil, llm.ErrUnknownProvider } - if err := check(p, s, key); err != nil { + if err := check(p, s, key, needModel); err != nil { return nil, fmt.Errorf("%s: %w", p.Name, err) } base := p.BaseURL @@ -233,8 +260,9 @@ func fromSettings(s llm.Settings, key string) (*configured, error) { return cp, nil } -// check validates s and key against p. Its errors never quote a value. -func check(p Preset, s llm.Settings, key string) error { +// check validates s and key against p, and that s names a model when +// needModel is set. Its errors never quote a value. +func check(p Preset, s llm.Settings, key string, needModel bool) error { switch { case p.Key == KeyRequired && key == "": return llm.ErrMissingKey @@ -249,7 +277,7 @@ func check(p Preset, s llm.Settings, key string) error { case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL: return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting) } - if s.Model == "" { + if needModel && s.Model == "" { return llm.ErrMissingModel } if p.RequiresPublicURL && s.PublicURL == "" { diff --git a/internal/ai/llm/presets/presets_test.go b/internal/ai/llm/presets/presets_test.go index 41e4b3f..2b2645f 100644 --- a/internal/ai/llm/presets/presets_test.go +++ b/internal/ai/llm/presets/presets_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "testing" @@ -660,3 +661,35 @@ func TestFromSettingsRetriesTransportTimeout(t *testing.T) { t.Errorf("sent %d requests, want 2", st.sent) } } + +// ForAccount builds without a model, so a key can be verified and the +// model list read before a model is chosen, but keeps every other check. +func TestForAccount(t *testing.T) { + if _, err := ForAccount(llm.Settings{Provider: "openai"}, testKey); err != nil { + t.Errorf("ForAccount without a model = %v", err) + } + for _, c := range []struct { + s llm.Settings + key string + want error + }{ + {llm.Settings{Provider: "gemini"}, testKey, llm.ErrUnknownProvider}, + {llm.Settings{Provider: "openai"}, "", llm.ErrMissingKey}, + {llm.Settings{Provider: "ollama"}, "", llm.ErrMissingBaseURL}, + {llm.Settings{Provider: "openrouter"}, testKey, llm.ErrMissingPublicURL}, + } { + if _, err := ForAccount(c.s, c.key); !errors.Is(err, c.want) { + t.Errorf("ForAccount(%s) = %v, want %v", c.s.Provider, err, c.want) + } + } + if _, err := FromSettings(llm.Settings{Provider: "openai"}, testKey); !errors.Is(err, llm.ErrMissingModel) { + t.Errorf("FromSettings without a model = %v, want ErrMissingModel", err) + } +} + +func TestNames(t *testing.T) { + want := []string{"anthropic", "ollama", "openai", "openai_compatible", "openrouter", "xai"} + if got := Names(); !slices.Equal(got, want) { + t.Errorf("Names = %v, want %v", got, want) + } +} diff --git a/internal/household/defaults.go b/internal/household/defaults.go index c74ff80..34fbd51 100644 --- a/internal/household/defaults.go +++ b/internal/household/defaults.go @@ -11,7 +11,8 @@ import ( ) // DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4). -// SeedAISettings stores it on first boot and the Admin reset restores it. +// SeedAISettings stores it on first boot and aisettings.Service.ResetPrompt +// restores it. const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners similar to their regular rotation, nudging them to try something a little new each week. Prefer weeknight-friendly recipes with common grocery-store ingredients. diff --git a/internal/server/aisettings.go b/internal/server/aisettings.go new file mode 100644 index 0000000..88db4fd --- /dev/null +++ b/internal/server/aisettings.go @@ -0,0 +1,145 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes +// the stored key. +type apiKeyBody struct { + APIKey *string `json:"api_key"` +} + +// modelsResponse is the GET /settings/ai/models body. +type modelsResponse struct { + Models []aisettings.Model `json:"models"` +} + +// No response from these handlers holds the API key: the settings carry +// only its last four characters, and provider errors and model lists are +// redacted by package aisettings. + +func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Get(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "get AI settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxAISettingsBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch)) + } +} + +// putAIKey takes the API key in api_key; the response, like GET's, holds +// only its last four characters. +func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody)) + if err == nil && body.APIKey == nil { + err = errors.New("api_key: is required") + } + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey)) + } +} + +func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context())) + } +} + +// postAIVerify checks the stored key with the provider and returns the +// settings with key_verified_at, or the failure (see writeAIFailure). +func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Verify(r.Context()) + if err != nil { + writeAIFailure(w, r, logger, "verify AI key", err) + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// getAIModels returns the provider's live model list, or the failure (see +// writeAIFailure). +func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + models, err := svc.Models(r.Context()) + if err != nil { + writeAIFailure(w, r, logger, "list AI models", err) + return + } + writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models}) + } +} + +// writeAISettings returns a func writing a Service result: the settings, +// 400 invalid_settings for a *aisettings.ValidationError, or 500. +func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) { + return func(settings aisettings.Settings, err error) { + var invalid *aisettings.ValidationError + switch { + case errors.As(err, &invalid): + writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error()) + case err != nil: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + default: + writeJSON(w, r, logger, http.StatusOK, settings) + } + } +} + +// writeAIFailure writes a Verify or Models error: 409 not_configured when +// the settings cannot build a provider, 409 settings_changed when they +// changed during verification, and for a failed provider call a 502 (504 +// for a timeout) whose code names the llm typed error: auth_failed, +// rate_limited, timeout, bad_output or provider_error. Rate limits and +// timeouts are retryable. Anything else is 500. +func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + switch { + case errors.Is(err, aisettings.ErrNotConfigured): + writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error()) + case errors.Is(err, aisettings.ErrChanged): + writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error()) + case errors.Is(err, aisettings.ErrProviderFailed): + logger.WarnContext(r.Context(), action+" failed", "error", err) + status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()} + switch { + case errors.Is(err, llm.ErrAuth): + e.Code = "auth_failed" + case errors.Is(err, llm.ErrRateLimited): + e.Code, e.Retryable = "rate_limited", true + case errors.Is(err, llm.ErrTimeout): + status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true + case errors.Is(err, llm.ErrBadOutput): + e.Code = "bad_output" + } + writeAPIError(w, r, logger, status, e) + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go new file mode 100644 index 0000000..8bbd281 --- /dev/null +++ b/internal/server/aisettings_test.go @@ -0,0 +1,261 @@ +package server + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "testing/fstest" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +const ( + aiPath = "/api/v1/settings/ai" + aiKeyPath = "/api/v1/settings/ai/api-key" + aiVerifyPath = "/api/v1/settings/ai/verify" + aiModelsPath = "/api/v1/settings/ai/models" + aiResetPromptPath = "/api/v1/settings/ai/reset-prompt" + testAIKey = "sk-test-secret-ai-key-7d21" + testWrongAIKey = "sk-test-wrong-ai-key-0b9e" +) + +// newAIRouter returns a router with a fresh, seeded AI settings service. +func newAIRouter(t *testing.T) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r +} + +// newModelsServer is an OpenAI-compatible server whose GET /models accepts +// only testAIKey. While echo is set it answers 500 with a body quoting the +// Authorization header it received, so a response that passed the vendor +// body through unredacted would leak the key. Its model list holds a model +// whose ID and name quote the key. +func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + switch { + case r.URL.Path != "/v1/models": + http.NotFound(w, r) + case echo.Load(): + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, `{"error":{"message":"upstream failed for `+auth+`"}}`) + case auth != "Bearer "+testAIKey: + w.WriteHeader(http.StatusUnauthorized) + io.WriteString(w, `{"error":{"message":"invalid key `+auth+`"}}`) + default: + io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"},`+ + `{"id":"gpt-`+testAIKey+`","name":"Leaky `+testAIKey+`"}]}`) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func errorCode(body map[string]any) any { + e, _ := body["error"].(map[string]any) + return e["code"] +} + +// TestAISettingsAPI drives every /settings/ai route and scans every +// response body for the API keys. +func TestAISettingsAPI(t *testing.T) { + t.Parallel() + router := newAIRouter(t) + var echo atomic.Bool + vendor := newModelsServer(t, &echo) + var bodies []string + call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + rec, decoded := serve(t, router, signedIn(method, target, r)) + bodies = append(bodies, rec.Body.String()) + return rec, decoded + } + + // The seed: anthropic, the default persona, no key. + rec, body := call(http.MethodGet, aiPath, "") + if rec.Code != http.StatusOK || body["provider"] != "anthropic" || body["system_prompt"] != household.DefaultPersonaPrompt || + body["temperature"] != 0.7 || body["api_key_last4"] != nil || body["key_verified_at"] != nil { + t.Errorf("GET seed = %d %v", rec.Code, body) + } + for _, field := range []string{"provider", "base_url", "model", "temperature", "system_prompt", "api_key_last4", "key_verified_at"} { + if _, ok := body[field]; !ok { + t.Errorf("GET has no %s field: %v", field, body) + } + } + for _, path := range []string{aiVerifyPath, aiModelsPath} { + method := http.MethodPost + if path == aiModelsPath { + method = http.MethodGet + } + if rec, body := call(method, path, ""); rec.Code != http.StatusConflict || errorCode(body) != "not_configured" { + t.Errorf("%s %s without a key = %d %v, want 409 not_configured", method, path, rec.Code, body) + } + } + + for _, bad := range []string{ + `{}`, + `{"provider":"google"}`, + `{"provider":"openai_compatible","base_url":"not a url"}`, + `{"base_url":"https://evil.example"}`, + `{"temperature":1.1}`, + `{"temperature":-0.1}`, + `{"temperature":0.25}`, + `{"temperature":"0.5"}`, + `{"model":""}`, + `{"model":null}`, + `{"system_prompt":"` + strings.Repeat("é", 8001) + `"}`, + `{"system_prompt":"` + strings.Repeat("€", 8001) + `"}`, + `{"system_prompt":"` + strings.Repeat("a", maxAISettingsBody) + `"}`, + `{"api_key":"` + testAIKey + `"}`, + } { + if rec, body := call(http.MethodPatch, aiPath, bad); rec.Code != http.StatusBadRequest { + t.Errorf("PATCH %.80s = %d %v, want 400", bad, rec.Code, body) + } + } + + // The longest prompt fits the body limit however its 8,000 characters + // are written: three-byte, four-byte, or as \u-escaped surrogate pairs. + for _, char := range []string{"€", "🍲", `\ud83c\udf72`} { + rec, body := call(http.MethodPatch, aiPath, `{"system_prompt":"`+strings.Repeat(char, 8000)+`"}`) + if prompt, _ := body["system_prompt"].(string); rec.Code != http.StatusOK || utf8.RuneCountInString(prompt) != 8000 { + t.Errorf("PATCH 8,000 × %s = %d, %d characters", char, rec.Code, utf8.RuneCountInString(prompt)) + } + } + + rec, body = call(http.MethodPatch, aiPath, `{"provider":"openai_compatible","base_url":"`+vendor.URL+`/v1","model":"gpt-test","temperature":0.3,"system_prompt":"`+strings.Repeat("é", 8000)+`"}`) + if rec.Code != http.StatusOK || body["provider"] != "openai_compatible" || body["base_url"] != vendor.URL+"/v1" || + body["model"] != "gpt-test" || body["temperature"] != 0.3 { + t.Errorf("PATCH openai_compatible = %d %v", rec.Code, body) + } + + for _, bad := range []string{`{}`, `{"api_key":"short"}`, `{"api_key":"sk-line\nbreak"}`, `{"api_key":null}`, `{"key":"` + testAIKey + `"}`} { + if rec, body := call(http.MethodPut, aiKeyPath, bad); rec.Code != http.StatusBadRequest { + t.Errorf("PUT api-key %s = %d %v, want 400", bad, rec.Code, body) + } + } + + // A rejected key is a typed auth failure and records nothing. + rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testWrongAIKey+`"}`) + if rec.Code != http.StatusOK || body["api_key_last4"] != "0b9e" || body["key_verified_at"] != nil { + t.Errorf("PUT wrong key = %d %v", rec.Code, body) + } + if rec, body := call(http.MethodPost, aiVerifyPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" { + t.Errorf("POST verify with the wrong key = %d %v, want 502 auth_failed", rec.Code, body) + } + if rec, body := call(http.MethodGet, aiModelsPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" { + t.Errorf("GET models with the wrong key = %d %v, want 502 auth_failed", rec.Code, body) + } + + rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testAIKey+`"}`) + if rec.Code != http.StatusOK || body["api_key_last4"] != "7d21" { + t.Errorf("PUT key = %d %v", rec.Code, body) + } + if _, ok := body["api_key"]; ok { + t.Errorf("PUT response has an api_key field: %v", body) + } + + // The vendor quoting the key in an error body: the body excerpt + // reaches the response redacted. + echo.Store(true) + for _, c := range []struct{ method, path string }{{http.MethodPost, aiVerifyPath}, {http.MethodGet, aiModelsPath}} { + rec, body := call(c.method, c.path, "") + if rec.Code != http.StatusBadGateway || errorCode(body) != "provider_error" || !strings.Contains(rec.Body.String(), "upstream failed for Bearer [REDACTED]") { + t.Errorf("%s %s against an echoing vendor = %d %v, want 502 provider_error", c.method, c.path, rec.Code, body) + } + } + echo.Store(false) + + rec, body = call(http.MethodPost, aiVerifyPath, "") + if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["api_key_last4"] != "7d21" { + t.Errorf("POST verify = %d %v", rec.Code, body) + } + rec, body = call(http.MethodGet, aiPath, "") + if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["model"] != "gpt-test" || body["temperature"] != 0.3 { + t.Errorf("GET after verify = %d %v", rec.Code, body) + } + + rec, body = call(http.MethodGet, aiModelsPath, "") + models, _ := body["models"].([]any) + if rec.Code != http.StatusOK || len(models) != 3 { + t.Fatalf("GET models = %d %v", rec.Code, body) + } + if m := models[1].(map[string]any); m["id"] != "gpt-test-mini" || m["display_name"] != "GPT Test Mini" { + t.Errorf("GET models[1] = %v", m) + } + if m := models[2].(map[string]any); m["id"] != "gpt-[REDACTED]" || m["display_name"] != "Leaky [REDACTED]" { + t.Errorf("GET models[2] = %v, want the key redacted", m) + } + + rec, body = call(http.MethodPatch, aiPath, `{"system_prompt":"Be brief."}`) + if rec.Code != http.StatusOK || body["system_prompt"] != "Be brief." || body["key_verified_at"] == nil { + t.Errorf("PATCH system_prompt = %d %v", rec.Code, body) + } + rec, body = call(http.MethodPost, aiResetPromptPath, "") + if rec.Code != http.StatusOK || body["system_prompt"] != household.DefaultPersonaPrompt { + t.Errorf("POST reset-prompt = %d %v", rec.Code, body) + } + + // The stored key is never pointed at another server or provider. + for _, patch := range []string{`{"base_url":"https://attacker.example/v1"}`, `{"provider":"openai","base_url":""}`} { + if rec, body := call(http.MethodPatch, aiPath, patch); rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_settings" { + t.Errorf("PATCH %s with a key stored = %d %v, want 400 invalid_settings", patch, rec.Code, body) + } + } + if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["key_verified_at"] != nil { + t.Errorf("PUT empty key = %d %v", rec.Code, body) + } + rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`) + if rec.Code != http.StatusOK || body["base_url"] != "https://llm.example/v1" { + t.Errorf("PATCH base_url without a key = %d %v", rec.Code, body) + } + + if len(bodies) < 30 { + t.Fatalf("scanned only %d response bodies", len(bodies)) + } + for _, b := range bodies { + for _, key := range []string{testAIKey, testWrongAIKey} { + if strings.Contains(b, key) { + t.Errorf("response holds an API key: %s", b) + } + } + } +} + +func TestAISettingsWritesNeedCSRF(t *testing.T) { + t.Parallel() + router := newAIRouter(t) + for _, c := range []struct{ method, path, body string }{ + {http.MethodPatch, aiPath, `{"temperature":0.5}`}, + {http.MethodPut, aiKeyPath, `{"api_key":"` + testAIKey + `"}`}, + {http.MethodPost, aiVerifyPath, ""}, + {http.MethodPost, aiResetPromptPath, ""}, + } { + req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body)) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("%s %s without the CSRF header = %d, want 403", c.method, c.path, rec.Code) + } + } + rec, body := serve(t, router, signedIn(http.MethodGet, aiPath, nil)) + if rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["temperature"] != 0.7 { + t.Errorf("GET after refused writes = %d %v, want the seed", rec.Code, body) + } +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 29f6257..d7fa939 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index fab7903..c464737 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 19f5022..cf52a47 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 384753d..4b60f41 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 84dc8ff..b32d9ca 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" @@ -31,18 +32,23 @@ type RouteMounter interface { // only from a peer in trusted (see resolveOrigin). version is reported by // GET /health; auth mounts the /auth routes; sessions guards /api/v1 and // /media; settings serves /api/v1/settings/household; search serves -// /api/v1/settings/recipe-search and its /test; runner serves +// /api/v1/settings/recipe-search and its /test; ai serves +// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; +// runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } if search == nil { return nil, errors.New("server: nil recipe search settings service") } + if ai == nil { + return nil, errors.New("server: nil AI settings service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -80,6 +86,12 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search)) apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search)) apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search)) + apiV1.Get("/settings/ai", getAISettings(logger, ai)) + apiV1.Patch("/settings/ai", patchAISettings(logger, ai)) + apiV1.Put("/settings/ai/api-key", putAIKey(logger, ai)) + apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai)) + apiV1.Get("/settings/ai/models", getAIModels(logger, ai)) + apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4efead6..8e16ff6 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -16,6 +16,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" @@ -42,7 +43,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -131,24 +132,54 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache { // configured, over a migrated temp database and a random secret box. func newTestSearch(t *testing.T) *recipes.SearchSettingsService { t.Helper() - key := make([]byte, secretbox.KeySize) - rand.Read(key) - box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) - if err != nil { - t.Fatal(err) - } - search, err := recipes.NewSearchSettingsService(newTestStore(t), box) + search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t)) if err != nil { t.Fatal(err) } return search } +// newTestBox returns a secret box under a random key. +func newTestBox(t *testing.T) *secretbox.Box { + t.Helper() + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + return box +} + +// newTestAI returns an AI settings service over a migrated temp database +// with the seed AI settings and a random secret box. +func newTestAI(t *testing.T) *aisettings.Service { + t.Helper() + st := newTestStore(t) + if err := household.SeedAISettings(context.Background(), st); err != nil { + t.Fatal(err) + } + ai, err := aisettings.New(st, newTestBox(t), "https://mealquest.example") + if err != nil { + t.Fatal(err) + } + return ai +} + +func TestNewRouterRequiresAISettings(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil AI settings service") + } +} + func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -157,7 +188,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -166,7 +197,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -175,7 +206,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings.go b/internal/server/settings.go index b0d3b04..489d092 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -9,14 +9,21 @@ import ( "log/slog" "net/http" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" ) // maxSettingsBody bounds a PATCH /settings/household or -// /settings/recipe-search request body. +// /settings/recipe-search, or PUT /settings/ai/api-key, request body. const maxSettingsBody = 16 << 10 +// maxAISettingsBody bounds a PATCH /settings/ai request body. It fits the +// longest valid system prompt at 12 bytes of JSON per character (a +// \u-escaped surrogate pair) on top of maxSettingsBody for the other +// fields, so only aisettings.MaxSystemPromptChars limits the prompt. +const maxAISettingsBody = maxSettingsBody + 12*aisettings.MaxSystemPromptChars + // settingsPatch is the PATCH /settings/household body. Every field is // optional; unknown fields and null values are refused. type settingsPatch struct { @@ -124,13 +131,18 @@ func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, stat // writeError responds with status and a non-retryable API error. func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) { - body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}}) + writeAPIError(w, r, logger, status, apiError{Code: code, Message: message}) +} + +// writeAPIError responds with status and e. +func writeAPIError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, e apiError) { + body, err := json.Marshal(errorBody{Error: e}) if err != nil { panic(err) // string and bool fields cannot fail to marshal } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if _, err := w.Write(body); err != nil { - logger.WarnContext(r.Context(), "write error response", "code", code, "error", err) + logger.WarnContext(r.Context(), "write error response", "code", e.Code, "error", err) } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index e3fb3fb..f0072fe 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } -- 2.55.0 From de4f539aea04626808862319e6c2482f0d9c4e3d Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 09:50:17 -0400 Subject: [PATCH 2/9] Settings home screen (mock 1p) (#81) Co-authored-by: dev_agent --- web/src/App.test.tsx | 17 +- web/src/App.tsx | 10 +- web/src/settings/SettingsPage.module.css | 153 ++++++++++++++ web/src/settings/SettingsPage.test.tsx | 258 +++++++++++++++++++++++ web/src/settings/SettingsPage.tsx | 151 +++++++++++++ web/src/settings/api.ts | 202 ++++++++++++++++++ web/src/shell/AppShell.test.tsx | 10 +- web/src/shell/BottomBar.test.tsx | 8 +- web/src/test/ApiProviders.tsx | 30 +++ web/src/test/fetchStub.ts | 14 ++ 10 files changed, 846 insertions(+), 7 deletions(-) create mode 100644 web/src/settings/SettingsPage.module.css create mode 100644 web/src/settings/SettingsPage.test.tsx create mode 100644 web/src/settings/SettingsPage.tsx create mode 100644 web/src/settings/api.ts create mode 100644 web/src/test/ApiProviders.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 8626068..59636b8 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -4,8 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApiClient, createVerifiedSession } from './api/index.ts' import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' -import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts' +import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' +import { SETTINGS_API } from './test/ApiProviders.tsx' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') @@ -46,7 +47,7 @@ describe('App routes', () => { ]) }) - it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( + it.each(routes.filter(({ path }) => path !== '/settings').map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( '%s renders its placeholder page inside the shell', async (path, title) => { mockViewport(390) @@ -57,6 +58,18 @@ describe('App routes', () => { expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() }, ) + + it('/settings renders the settings home inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + '/api/v1/me': () => jsonResponse(200, ME), + ...SETTINGS_API, + }) + renderAt('/settings') + const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument() + }) }) describe('App bootstrap', () => { diff --git a/web/src/App.tsx b/web/src/App.tsx index 687c59f..400d3a5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' @@ -6,6 +6,7 @@ import { AuthBootstrap } from './AuthBootstrap.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' +import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' // Every route the app serves, with the heading its placeholder page shows. @@ -27,13 +28,18 @@ export const routes = [ { path: '/settings/calendar', title: 'Calendar' }, ] as const +// The screens built so far; every other route shows its placeholder page. +const screens: Partial> = { + '/settings': , +} + // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. export function AppRoutes() { return ( }> {routes.map(({ path, title }) => ( - } /> + } /> ))} diff --git a/web/src/settings/SettingsPage.module.css b/web/src/settings/SettingsPage.module.css new file mode 100644 index 0000000..54dc598 --- /dev/null +++ b/web/src/settings/SettingsPage.module.css @@ -0,0 +1,153 @@ +/* Settings home, handoff mock 1p. */ +.page { + max-width: 560px; + display: flex; + flex-direction: column; +} + +.title { + margin: 0; + padding-top: 6px; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; +} + +.banner { + display: flex; + align-items: center; + gap: 12px; + margin-top: 14px; + padding: 14px 16px; + border-radius: 26px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); +} + +.avatar { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); + font-family: var(--font-heading); + font-size: 18px; +} + +.bannerText { + flex: 1; + display: flex; + flex-direction: column; + margin: 0; + font-family: var(--font-body); + font-size: 13px; + line-height: 1.35; +} + +.bannerTitle { + font-size: 15px; + font-weight: 700; +} + +.alert { + margin-top: 14px; +} + +.groupLabel { + margin: 0; + padding: 18px 4px 8px; + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-neutral-700); +} + +.group { + padding: 0; + border-radius: 26px; +} + +.admin { + box-shadow: inset 0 0 0 2px var(--color-accent-300); +} + +.rows { + margin: 0; + padding: 0; + list-style: none; +} + +/* Rows in one card are split by a hairline inset to the row padding. */ +.item + .item { + border-top: 1px solid var(--color-neutral-300); + margin: 0 16px; +} + +.item + .item .row { + padding-left: 0; + padding-right: 0; +} + +.row { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 14px; + min-width: 44px; + min-height: 44px; + padding: 14px 16px; + border-radius: 26px; + color: var(--color-text); + text-decoration: none; +} + +.icon { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 50%; + background: var(--color-bg); +} + +.icon svg { + width: 18px; + height: 18px; +} + +.body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + font-family: var(--font-body); +} + +.rowTitle { + font-size: 15px; + font-weight: 700; +} + +.status { + font-size: 12px; + color: var(--color-neutral-700); +} + +.statusSkeleton { + width: 60%; + height: 12px; + margin-top: 3px; + background: var(--color-neutral-300); +} + +.tag { + flex: none; +} diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx new file mode 100644 index 0000000..30c0639 --- /dev/null +++ b/web/src/settings/SettingsPage.test.tsx @@ -0,0 +1,258 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, within } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { SETTINGS_API } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { formatClockTime, modelDisplayName } from './api.ts' +import { SettingsPage } from './SettingsPage.tsx' + +const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' } +const AI = { provider: 'anthropic', model: 'claude-sonnet-4-5' } + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const REGULARS_PATH = '/api/v1/dinners?status=regular' +const CALENDAR_PATH = '/api/v1/settings/calendar' +const NOTIFICATIONS_PATH = '/api/v1/settings/notifications' + +// stubSettings answers as today's server does (see SETTINGS_API), with +// overrides for the paths a test changes. +function stubSettings(overrides: Record Response> = {}) { + return stubFetchByPath({ + ...SETTINGS_API, + '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD), + '/api/v1/settings/ai': () => jsonResponse(200, AI), + ...overrides, + }) +} + +function CurrentPath() { + return

{useLocation().pathname}

+} + +function renderSettings() { + const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage)) + return render( + + + + + } /> + } /> + + + + , + ) +} + +function row(name: string) { + return screen.getByRole('link', { name: new RegExp(`^${name}`) }) +} + +describe('SettingsPage', () => { + it('renders the title and the shared household banner', async () => { + stubSettings() + renderSettings() + expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument() + expect(screen.getByText('Our kitchen')).toBeInTheDocument() + expect(screen.getByText('Shared household · changes apply to everyone')).toBeInTheDocument() + expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument() + }) + + it('groups the rows under Household, Alerts and Admin', async () => { + stubSettings() + renderSettings() + const group = (name: string) => within(screen.getByRole('region', { name })) + expect(group('Household').getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([ + '/settings/regulars', + '/settings/calendar', + '/settings/household', + ]) + expect(group('Alerts').getByRole('link')).toHaveAttribute('href', '/settings/notifications') + expect(group('Admin').getByRole('link')).toHaveAttribute('href', '/settings/ai') + await screen.findByText('Anthropic · Claude Sonnet') + }) + + it('reads every status line from the server', async () => { + const fetchMock = stubSettings() + renderSettings() + expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() + expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument() + expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() + expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ + REGULARS_PATH, + '/api/v1/settings/ai', + CALENDAR_PATH, + '/api/v1/settings/household', + NOTIFICATIONS_PATH, + ]) + }) + + it('shows the not-yet-available states while the server has no endpoint for them', async () => { + stubSettings() + renderSettings() + expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument() + expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument() + expect( + await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'), + ).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => { + stubSettings({ + [REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }), + [CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }), + [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }), + }) + renderSettings() + expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument() + expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument() + expect( + await within(row('Notifications & Google Chat')).findByText('Push on · Dinner Crew space'), + ).toBeInTheDocument() + }) + + it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => { + stubSettings({ + [REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }), + [CALENDAR_PATH]: () => jsonResponse(200, { connected: false }), + [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }), + }) + renderSettings() + expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument() + expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument() + expect( + await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'), + ).toBeInTheDocument() + }) + + it('reports a status endpoint failure other than a missing endpoint', async () => { + stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') }) + renderSettings() + expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message') + expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar') + }) + + it('refuses a connected calendar without a name', async () => { + stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) }) + renderSettings() + expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status') + }) + + it('shows another provider and model as the settings name them', async () => { + stubSettings({ + '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'openai_compatible', model: 'llama3.1:8b' }), + }) + renderSettings() + expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument() + }) + + it('says no model is chosen for the seeded AI settings', async () => { + stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: '' }) }) + renderSettings() + expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument() + }) + + it('marks the AI assistant row Admin and rings its card in grape-300', async () => { + stubSettings() + renderSettings() + expect(within(row('AI assistant')).getByText('Admin')).toBeInTheDocument() + const adminCard = screen.getByRole('region', { name: 'Admin' }).querySelector('ul')!.parentElement! + expect(declaredStyle(adminCard).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)') + const householdCard = screen.getByRole('region', { name: 'Household' }).querySelector('ul')!.parentElement! + expect(declaredStyle(householdCard).boxShadow).toBe('') + await screen.findByText('Anthropic · Claude Sonnet') + }) + + it.each([ + ['Regular dinners', '/settings/regulars'], + ['Google Calendar', '/settings/calendar'], + ['Household', '/settings/household'], + ['Notifications & Google Chat', '/settings/notifications'], + ['AI assistant', '/settings/ai'], + ])('the %s row navigates to %s', async (name, path) => { + stubSettings() + renderSettings() + await screen.findByText('Anthropic · Claude Sonnet') + fireEvent.click(row(name)) + expect(screen.getByTestId('path')).toHaveTextContent(path) + }) + + it('gives every row a tap target of at least 44 px', async () => { + stubSettings() + renderSettings() + await screen.findByText('Anthropic · Claude Sonnet') + for (const link of screen.getAllByRole('link')) expectMinHitArea(link) + }) + + it('reports a failed status with a retry that loads it', async () => { + let householdCalls = 0 + stubSettings({ + '/api/v1/settings/household': () => { + householdCalls += 1 + return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD) + }, + }) + renderSettings() + expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message') + expect(screen.getByText('Anthropic · Claude Sonnet')).toBeInTheDocument() + expect(row('Household')).toHaveAccessibleName('Household') + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('refuses an AI provider it has no name for', async () => { + stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'mystery', model: 'm1' }) }) + renderSettings() + expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"') + expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin') + }) + + it('refuses a dinner time that is not HH:MM', async () => { + stubSettings({ + '/api/v1/settings/household': () => jsonResponse(200, { timezone: 'UTC', dinner_time: '5:30pm' }), + }) + renderSettings() + expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM') + }) +}) + +describe('modelDisplayName', () => { + it.each([ + ['claude-sonnet-4-5', 'Claude Sonnet'], + ['claude-opus-4-1-20250805', 'Claude Opus'], + ['claude-3-5-haiku-20241022', 'Claude Haiku'], + ['claude-fable-5-1', 'Claude Fable'], + ['gpt-4o', 'gpt-4o'], + ['anthropic/claude-sonnet-4.5', 'anthropic/claude-sonnet-4.5'], + ])('names %s %s', (model, name) => { + expect(modelDisplayName(model)).toBe(name) + }) +}) + +describe('formatClockTime', () => { + it.each([ + ['00:05', '12:05 am'], + ['09:00', '9:00 am'], + ['12:00', '12:00 pm'], + ['17:30', '5:30 pm'], + ['23:59', '11:59 pm'], + ])('formats %s as %s', (time, formatted) => { + expect(formatClockTime(time)).toBe(formatted) + }) + + it('refuses a time that is not HH:MM', () => { + expect(() => formatClockTime('24:00')).toThrow('not a 24-hour HH:MM time') + }) +}) diff --git a/web/src/settings/SettingsPage.tsx b/web/src/settings/SettingsPage.tsx new file mode 100644 index 0000000..91e0a28 --- /dev/null +++ b/web/src/settings/SettingsPage.tsx @@ -0,0 +1,151 @@ +import type { UseQueryResult } from '@tanstack/react-query' +import type { LucideIcon } from 'lucide-react' +import { Bell, Calendar, ChevronRight, Crosshair, Heart, House } from 'lucide-react' +import { useId, type ReactNode } from 'react' +import { Link } from 'react-router' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import { + aiStatus, + calendarStatus, + formatClockTime, + notificationsStatus, + regularDinnersStatus, + useAISettings, + useCalendarStatus, + useHouseholdSettings, + useNotificationsStatus, + useRegularDinnersCount, +} from './api.ts' +import styles from './SettingsPage.module.css' + +// Settings home (handoff mock 1p). Each row opens its settings page and +// shows a live status line. Regular dinners, Google Calendar and +// Notifications & Google Chat read endpoints their features have not served +// yet; until then each shows its not-yet-available state (see api.ts). +export function SettingsPage() { + const household = useHouseholdSettings() + const ai = useAISettings() + const regulars = useRegularDinnersCount() + const calendar = useCalendarStatus() + const notifications = useNotificationsStatus() + const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError) + + return ( +
+

Settings

+
+ +

+ Our kitchen + Shared household · changes apply to everyone +

+
+ {failed.length > 0 && ( +
+ failed.forEach((query) => void query.refetch())}> + Settings could not load: {failed.map((query) => query.error?.message).join('; ')} + +
+ )} + + + + `${s.timezone} · Dinner at ${formatClockTime(s.dinner_time)}`)} + /> + + + + + + Admin} + /> + +
+ ) +} + +// status is a row's status line: a skeleton while its query loads, the +// formatted data once it has, and nothing when it failed (the error banner +// says why). +function status(query: UseQueryResult, format: (data: T) => string): ReactNode { + if (query.isPending) return + if (query.isError) return null + return format(query.data) +} + +type GroupProps = { + label: string + // The Admin group's card carries the grape-300 inset ring. + admin?: boolean + children: ReactNode +} + +function Group({ label, admin = false, children }: GroupProps) { + const id = useId() + return ( +
+

+ {label} +

+ +
    {children}
+
+
+ ) +} + +type SettingsRowProps = { + to: string + icon: LucideIcon + title: string + status?: ReactNode + tag?: ReactNode +} + +function SettingsRow({ to, icon, title, status, tag }: SettingsRowProps) { + return ( +
  • + + + + + + {title} + {status && {status}} + + {tag} + + +
  • + ) +} diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts new file mode 100644 index 0000000..638ea0a --- /dev/null +++ b/web/src/settings/api.ts @@ -0,0 +1,202 @@ +import { useQuery } from '@tanstack/react-query' +import { ApiError, useApi, type ApiClient } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' + +export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household' +export const AI_SETTINGS_PATH = '/api/v1/settings/ai' +// TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's +// dinners read API. Until it lands the server answers 404 and the Regular +// dinners row says the count is unavailable. +export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular' +// TODO(Feature 16): the Google Calendar integration status. Until Feature 16 +// serves it the server answers 404, which is the truth: not connected. +export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar' +// TODO(notifications feature): the Web Push and Google Chat status. Until it +// is served the server answers 404, which is the truth: push is off and no +// Google Chat space is connected. +export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications' + +// HouseholdSettings is the part of GET /api/v1/settings/household the +// settings pages read. +export type HouseholdSettings = { + timezone: string + // Zero-padded 24-hour HH:MM. + dinner_time: string +} + +// aiProviderLabels names every AI provider preset the server accepts +// (internal/ai/llm/presets) as the household reads it. +export const aiProviderLabels = { + anthropic: 'Anthropic', + openai: 'OpenAI', + openrouter: 'OpenRouter', + xai: 'xAI', + ollama: 'Ollama', + openai_compatible: 'OpenAI-compatible', +} as const + +export type AIProvider = keyof typeof aiProviderLabels + +// AISettings is the part of GET /api/v1/settings/ai the settings pages read. +export type AISettings = { + provider: AIProvider + model: string +} + +function isAIProvider(value: string): value is AIProvider { + return Object.hasOwn(aiProviderLabels, value) +} + +const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/ + +// useHouseholdSettings refuses a dinner time that is not HH:MM, so +// formatClockTime never sees one. +export function useHouseholdSettings() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: ['settings', 'household'], + queryFn: async () => { + const settings = await apiFetch(HOUSEHOLD_SETTINGS_PATH) + if (!clockTime.test(settings.dinner_time)) { + throw new Error(`GET ${HOUSEHOLD_SETTINGS_PATH}: dinner_time "${settings.dinner_time}" is not HH:MM`) + } + return { timezone: settings.timezone, dinner_time: settings.dinner_time } satisfies HouseholdSettings + }, + }) +} + +// useAISettings refuses a provider this client has no name for, so the +// settings never show a value the household cannot read. +export function useAISettings() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: ['settings', 'ai'], + queryFn: async () => { + const settings = await apiFetch<{ provider: string; model: string }>(AI_SETTINGS_PATH) + if (!isAIProvider(settings.provider)) { + throw new Error(`GET ${AI_SETTINGS_PATH}: unknown AI provider "${settings.provider}"`) + } + return { provider: settings.provider, model: settings.model } satisfies AISettings + }, + }) +} + +// aiStatus is the AI assistant row's status line: the provider and the +// model's display name, or that no model is chosen yet (the seed). +export function aiStatus(settings: AISettings): string { + const provider = aiProviderLabels[settings.provider] + if (settings.model === '') return `${provider} · No model chosen` + return `${provider} · ${modelDisplayName(settings.model)}` +} + +// Claude model ids, current ("claude-sonnet-4-5") and older +// ("claude-3-5-sonnet-20241022"), name their family. +const claudeModel = /^claude-(?:[0-9-]+-)?(opus|sonnet|haiku|fable)(?:-|$)/ +const claudeFamilies: Record = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', fable: 'Fable' } + +// modelDisplayName names a model as the handoff does ("Claude Sonnet"); a +// model with no known family is shown by its id. +export function modelDisplayName(model: string): string { + const family = claudeModel.exec(model)?.[1] + return family === undefined ? model : `Claude ${claudeFamilies[family]}` +} + +// fetchUnlessAbsent GETs path and parses it, or returns null when the server +// has no such endpoint yet (404 not_found). Every other failure is an error. +async function fetchUnlessAbsent( + apiFetch: ApiClient['apiFetch'], + path: string, + parse: (body: unknown) => T, +): Promise { + let body: unknown + try { + body = await apiFetch(path) + } catch (error) { + if (error instanceof ApiError && error.status === 404 && error.code === 'not_found') return null + throw error + } + return parse(body) +} + +// useRegularDinnersCount is how many Regulars the household has, or null +// while the dinners read API does not exist. +export function useRegularDinnersCount() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: ['dinners', 'regular', 'count'], + queryFn: () => + fetchUnlessAbsent(apiFetch, REGULAR_DINNERS_PATH, (body) => { + if (!isRecord(body) || !Array.isArray(body.dinners)) { + throw new Error(`GET ${REGULAR_DINNERS_PATH}: response has no dinners list`) + } + return body.dinners.length + }), + }) +} + +export function regularDinnersStatus(count: number | null): string { + if (count === null) return 'Rotation count unavailable' + if (count === 0) return 'None on rotation yet' + return `${count} on rotation` +} + +// CalendarStatus is the Google Calendar integration status; calendar_name +// is the connected calendar's name. +export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string } + +export function useCalendarStatus() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: ['settings', 'calendar'], + queryFn: () => + fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => { + if (isRecord(body) && body.connected === false) return { connected: false } + if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') { + return { connected: true, calendar_name: body.calendar_name } + } + throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`) + }), + }) +} + +export function calendarStatus(status: CalendarStatus | null): string { + if (status === null || !status.connected) return 'Not connected' + return `Connected · ${status.calendar_name}` +} + +// NotificationsStatus is whether Web Push is on and the connected Google +// Chat space's name, null when none is. +export type NotificationsStatus = { push: boolean; chat_space: string | null } + +export function useNotificationsStatus() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: ['settings', 'notifications'], + queryFn: () => + fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => { + if ( + !isRecord(body) || + typeof body.push !== 'boolean' || + (body.chat_space !== null && typeof body.chat_space !== 'string') + ) { + throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`) + } + return { push: body.push, chat_space: body.chat_space } + }), + }) +} + +// notificationsStatus is the handoff's "Push on · Dinner Crew space". +export function notificationsStatus(status: NotificationsStatus | null): string { + const push = status?.push === true ? 'Push on' : 'Push off' + return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}` +} + +// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm". +export function formatClockTime(time: string): string { + const match = clockTime.exec(time) + if (match === null) throw new Error(`not a 24-hour HH:MM time: "${time}"`) + const hours = Number(match[1]) + const suffix = hours < 12 ? 'am' : 'pm' + return `${hours % 12 === 0 ? 12 : hours % 12}:${match[2]} ${suffix}` +} diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 03d7bfd..153c5de 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -1,9 +1,11 @@ import { render, screen, within } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AppRoutes } from '../App.tsx' +import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx' import { declaredStyle, outerSize } from '../test/cssRules.ts' import { expectMinHitArea } from '../test/hitArea.ts' +import { stubFetchByPath } from '../test/fetchStub.ts' import { mockViewport } from '../test/matchMedia.ts' import bottomBarStyles from './BottomBar.module.css' import railStyles from './Rail.module.css' @@ -16,13 +18,17 @@ const DESKTOP = 1440 afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() }) function renderAt(path: string, width: number) { const resize = mockViewport(width) + stubFetchByPath(SETTINGS_API) render( - + + + , ) return resize diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx index 3ff7db6..b8c5d3b 100644 --- a/web/src/shell/BottomBar.test.tsx +++ b/web/src/shell/BottomBar.test.tsx @@ -2,7 +2,9 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { AppRoutes } from '../App.tsx' +import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' +import { stubFetchByPath } from '../test/fetchStub.ts' import { mockViewport } from '../test/matchMedia.ts' import styles from './BottomBar.module.css' @@ -65,13 +67,17 @@ afterEach(() => { vi.restoreAllMocks() Reflect.deleteProperty(Element.prototype, 'animate') Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() }) function renderAt(path: string, reducedMotion = false) { mockViewport(MOBILE, { reducedMotion }) + stubFetchByPath(SETTINGS_API) render( - + + + , ) } diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx new file mode 100644 index 0000000..359d115 --- /dev/null +++ b/web/src/test/ApiProviders.tsx @@ -0,0 +1,30 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { useState, type ReactNode } from 'react' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts' + +// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as +// today's server does: the dinners, Google Calendar and notifications status +// endpoints do not exist yet. +export const SETTINGS_API = { + '/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), + '/api/v1/settings/calendar': () => errorResponse(404, 'not_found'), + '/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), + '/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }), + '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }), +} + +// ApiProviders gives the pages under test what App gives them: an API client +// on https://mealquest.test and a fresh query client. +export function ApiProviders({ children }: { children: ReactNode }) { + const [api] = useState(() => + createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)), + ) + const [queryClient] = useState(createQueryClient) + return ( + + {children} + + ) +} diff --git a/web/src/test/fetchStub.ts b/web/src/test/fetchStub.ts index 9a7a932..27c7908 100644 --- a/web/src/test/fetchStub.ts +++ b/web/src/test/fetchStub.ts @@ -47,3 +47,17 @@ export function request(fetchMock: Mock, n: number) { export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } { return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() } } + +// stubFetchByPath replaces the global fetch with a mock that answers each +// path from responses with a fresh response from its factory, so requests +// sent in parallel need no order; a path not in responses fails the test. +// Undo with vi.unstubAllGlobals. +export function stubFetchByPath(responses: Record Response>): Mock { + const fetchMock = vi.fn((input, init) => { + const respond = Object.hasOwn(responses, String(input)) ? responses[String(input)] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`) + return Promise.resolve(respond()) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} -- 2.55.0 From e9a35a7f5745c1adbe56f0b8dd83efa25ed26f0f Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 10:22:07 -0400 Subject: [PATCH 3/9] Admin AI assistant screen (mock 1o) (#82) Co-authored-by: dev_agent --- web/src/App.test.tsx | 30 +- web/src/App.tsx | 7 +- web/src/main.tsx | 15 +- web/src/settings/AISettingsPage.module.css | 253 +++++++++ web/src/settings/AISettingsPage.test.tsx | 429 ++++++++++++++++ web/src/settings/AISettingsPage.tsx | 571 +++++++++++++++++++++ web/src/settings/SettingsPage.test.tsx | 10 +- web/src/settings/api.ts | 167 +++++- web/src/test/ApiProviders.tsx | 14 +- web/src/test/cssRules.ts | 6 +- web/src/test/dataRouter.tsx | 10 + web/src/ui/Select.module.css | 50 ++ web/src/ui/Select.test.tsx | 38 ++ web/src/ui/Select.tsx | 27 + web/src/ui/index.ts | 1 + 15 files changed, 1589 insertions(+), 39 deletions(-) create mode 100644 web/src/settings/AISettingsPage.module.css create mode 100644 web/src/settings/AISettingsPage.test.tsx create mode 100644 web/src/settings/AISettingsPage.tsx create mode 100644 web/src/test/dataRouter.tsx create mode 100644 web/src/ui/Select.module.css create mode 100644 web/src/ui/Select.test.tsx create mode 100644 web/src/ui/Select.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 59636b8..f144369 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,12 +1,12 @@ import { act, render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApiClient, createVerifiedSession } from './api/index.ts' import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' -import { SETTINGS_API } from './test/ApiProviders.tsx' +import { AI_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { InDataRouter } from './test/dataRouter.tsx' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') @@ -20,12 +20,15 @@ function renderAt(path: string, location = fakeLocation(path)) { function renderAppAt(path: string, location = fakeLocation(path)) { return render( - + - , + , ) } +// The routes that render a built screen rather than a placeholder page. +const SCREENS = { '/settings': true, '/settings/ai': true } + describe('App routes', () => { it('registers every handoff route plus the extra settings pages', () => { expect(routes.map((route) => route.path)).toEqual([ @@ -47,7 +50,7 @@ describe('App routes', () => { ]) }) - it.each(routes.filter(({ path }) => path !== '/settings').map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( + it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( '%s renders its placeholder page inside the shell', async (path, title) => { mockViewport(390) @@ -70,6 +73,19 @@ describe('App routes', () => { expect(screen.getByRole('main')).toContainElement(heading) expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument() }) + + it('/settings/ai renders the AI assistant screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + '/api/v1/me': () => jsonResponse(200, ME), + '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), + '/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }), + }) + renderAt('/settings/ai') + const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument() + }) }) describe('App bootstrap', () => { @@ -190,9 +206,9 @@ describe('App offline bootstrap', () => { goOffline() const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) render( - + - , + , ) expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) diff --git a/web/src/App.tsx b/web/src/App.tsx index 400d3a5..1f8553b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { AuthBootstrap } from './AuthBootstrap.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' +import { AISettingsPage } from './settings/AISettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' @@ -31,6 +32,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { '/settings': , + '/settings/ai': , } // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. @@ -56,8 +58,9 @@ function reloadPage() { window.location.reload() } -// App expects to be rendered inside a router (BrowserRouter in main.tsx, -// MemoryRouter in tests). +// App expects to be rendered by a data router's splat route +// (createBrowserRouter in main.tsx, createMemoryRouter in tests), so pages can +// block navigation. export default function App({ api, serviceWorker }: AppProps) { const [queryClient] = useState(createQueryClient) return ( diff --git a/web/src/main.tsx b/web/src/main.tsx index 63cd1b1..10ddcca 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router' +import { createBrowserRouter, RouterProvider } from 'react-router' import './styles/tokens.css' import './styles/base.css' import App from './App.tsx' @@ -14,10 +14,17 @@ if (!root) { const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage)) +// A data router, so pages can block navigation (useBlocker); App's own +// match every path under its splat route. +const router = createBrowserRouter([ + { + path: '*', + element: , + }, +]) + createRoot(root).render( - - - + , ) diff --git a/web/src/settings/AISettingsPage.module.css b/web/src/settings/AISettingsPage.module.css new file mode 100644 index 0000000..a004dbe --- /dev/null +++ b/web/src/settings/AISettingsPage.module.css @@ -0,0 +1,253 @@ +/* Admin · AI assistant, handoff mock 1o. */ +.page { + box-sizing: border-box; + min-height: 100%; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; +} + +.loading { + height: 320px; + border-radius: 26px; +} + +.columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; +} + +.model { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.label { + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.mono { + font-family: var(--font-mono); +} + +.hint, +.note, +.verified, +.error, +.status { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + line-height: 1.4; +} + +.hint, +.note { + color: var(--color-neutral-700); +} + +.verified { + font-weight: 700; + color: var(--color-accent-2-800); +} + +.error { + font-weight: 700; + color: var(--color-accent-700); +} + +.status { + color: var(--color-accent-2-800); +} + +.status:empty { + display: none; +} + +.key { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.keyRow { + display: flex; + align-items: center; + gap: 10px; + min-height: 56px; + padding: 0 6px 0 20px; + border: 1px solid var(--color-neutral-300); + border-radius: var(--radius-pill); + background: var(--color-surface); +} + +.keyValue { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--font-mono); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.replace { + flex: none; + min-width: 44px; + min-height: 44px; + padding: 0 18px; + border: 0; + border-radius: var(--radius-pill); + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.replace:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.keyStatus { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0 8px; +} + +.link { + min-width: 44px; + min-height: 44px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + text-decoration: underline; + cursor: pointer; +} + +.temperature { + display: flex; + flex-direction: column; +} + +.temperatureHeader { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.value { + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; +} + +.ends { + display: flex; + justify-content: space-between; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-700); +} + +.prompt { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.textarea { + box-sizing: border-box; + width: 100%; + min-height: 150px; + padding: 14px 18px; + border: 1px solid var(--color-neutral-300); + border-radius: 22px; + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + resize: vertical; +} + +.actions { + /* Mock 1o anchors Reset and Save changes at the bottom of the screen. */ + margin-top: auto; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 10px; + padding-top: 6px; +} + +.reset, +.save { + min-height: 52px; +} + +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.sheetTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.sheetText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +.sheetActions { + display: flex; + justify-content: flex-end; + gap: 10px; +} diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx new file mode 100644 index 0000000..4ddfc9f --- /dev/null +++ b/web/src/settings/AISettingsPage.test.tsx @@ -0,0 +1,429 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { Route, Routes, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { AI_SETTINGS } from '../test/ApiProviders.tsx' +import { InDataRouter } from '../test/dataRouter.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { AISettingsPage, formatVerifiedDate, maskedKey } from './AISettingsPage.tsx' + +const MODELS = { + models: [ + { id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }, + { id: 'claude-haiku-4-5', display_name: 'Claude Haiku' }, + ], +} + +const DEFAULT_PROMPT = 'You are MealQuest, the default persona.' + +type Responder = (body: unknown) => Response | Promise + +// stubAPI answers "METHOD path" from responders, each called with the +// request's decoded JSON body; GET /settings/ai and /settings/ai/models +// answer AI_SETTINGS and MODELS unless overridden. Any other request fails +// the test. +function stubAPI(responders: Record = {}): Mock { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), + 'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + return Promise.resolve(respond(body)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests other than GETs, as "METHOD path" and body. +function sent(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => ({ + request: `${init?.method} ${String(input)}`, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined, + })) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function CurrentPath() { + return

    {useLocation().pathname}

    +} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the page at /settings/ai in a data router, so its leave guard works. +async function renderPage() { + const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + render( + + + + + } /> + } /> + + + + , + ) + await screen.findByRole('option', { name: 'Claude Sonnet' }) +} + +const provider = () => screen.getByRole('combobox', { name: 'Provider' }) +const model = () => screen.getByRole('combobox', { name: 'Model' }) +const prompt = () => screen.getByRole('textbox', { name: 'System prompt' }) +const slider = () => screen.getByRole('slider', { name: 'Creativity (temperature)' }) +const save = () => screen.getByRole('button', { name: 'Save changes' }) + +describe('AISettingsPage', () => { + it('loads the settings as mock 1o lays them out', async () => { + stubAPI() + await renderPage() + expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument() + expect(screen.getByText('Settings · Admin')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'AI assistant' })).toBeInTheDocument() + expect(provider()).toHaveValue('anthropic') + expect(within(provider()).getByRole('option', { selected: true })).toHaveTextContent('Anthropic') + expect(model()).toHaveValue('claude-sonnet-4-5') + expect(within(model()).getAllByRole('option').map((o) => o.textContent)).toEqual([ + 'Claude Sonnet', + 'Claude Haiku', + 'Type a model id', + ]) + expect(screen.getByText('••••••••3f9a')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument() + expect(screen.getByText('Verified · Oct 1')).toBeInTheDocument() + expect(slider()).toHaveValue('0.7') + expect(slider()).toHaveAttribute('step', '0.1') + expect(screen.getByText('0.7')).toBeInTheDocument() + expect(screen.getByText('Familiar')).toBeInTheDocument() + expect(screen.getByText('Adventurous')).toBeInTheDocument() + expect(prompt()).toHaveValue(AI_SETTINGS.system_prompt) + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled() + expect(save()).toBeDisabled() + }) + + it('reports a load failure with a retry', async () => { + stubAPI({ 'GET /api/v1/settings/ai': () => errorResponse(500, 'internal') }) + const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage)) + render( + + + + + + + , + ) + expect(await screen.findByRole('alert')).toHaveTextContent('AI settings could not load: internal message') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + it('saves only the edited fields', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), + }) + await renderPage() + fireEvent.change(model(), { target: { value: 'claude-haiku-4-5' } }) + fireEvent.change(slider(), { target: { value: '0.4' } }) + expect(screen.getByText('0.4')).toBeInTheDocument() + fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } }) + expect(save()).toBeEnabled() + fireEvent.click(save()) + + expect(await screen.findByText('Changes saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { + request: 'PATCH /api/v1/settings/ai', + body: { model: 'claude-haiku-4-5', temperature: 0.4, system_prompt: 'Keep it cozy.' }, + }, + ]) + expect(save()).toBeDisabled() + expect(model()).toHaveValue('claude-haiku-4-5') + }) + + it('saves a typed model id', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), + }) + await renderPage() + expect(screen.queryByRole('textbox', { name: 'Model id' })).not.toBeInTheDocument() + fireEvent.change(model(), { target: { value: within(model()).getByRole('option', { name: 'Type a model id' }).getAttribute('value') } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'claude-opus-5-5' } }) + fireEvent.click(save()) + await screen.findByText('Changes saved.') + expect(sent(fetchMock)).toEqual([{ request: 'PATCH /api/v1/settings/ai', body: { model: 'claude-opus-5-5' } }]) + }) + + it('explains that a stored key must be removed before switching provider', async () => { + stubAPI({ + 'PATCH /api/v1/settings/ai': () => + jsonResponse(400, { + error: { + code: 'invalid_settings', + message: 'invalid AI settings: provider: cannot change while an API key is stored: remove the key first', + retryable: false, + }, + }), + }) + await renderPage() + fireEvent.change(provider(), { target: { value: 'openai' } }) + expect(screen.getByText(/An API key is stored\. Remove it \(Replace → Remove key\)/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() + fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'gpt-5' } }) + fireEvent.click(save()) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Not saved: invalid AI settings: provider: cannot change while an API key is stored') + expect(alert).toHaveTextContent('Remove it (Replace → Remove key) before changing the provider or base URL.') + }) + + it('gives the same guidance when a stored key locks the base URL', async () => { + stubAPI({ + 'GET /api/v1/settings/ai': () => + jsonResponse(200, { ...AI_SETTINGS, provider: 'openai_compatible', base_url: 'https://llm.example.com/v1' }), + 'PATCH /api/v1/settings/ai': () => + jsonResponse(400, { + error: { + code: 'invalid_settings', + message: 'invalid AI settings: base_url: cannot change while an API key is stored: remove the key first', + retryable: false, + }, + }), + }) + await renderPage() + fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'https://other.example.com/v1' } }) + expect(screen.getByText(/An API key is stored\. Remove it/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() + fireEvent.click(save()) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Remove it (Replace → Remove key) before changing the provider or base URL.', + ) + }) + + it('recovers a provider switch by removing the stored key, without undoing the switch', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }), + 'PATCH /api/v1/settings/ai': (body) => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null, ...(body as object) }), + }) + await renderPage() + fireEvent.change(provider(), { target: { value: 'ollama' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Base URL' }), { target: { value: 'http://localhost:11434' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'llama3.2' } }) + // The stored key stays in view, and removable, while Ollama is chosen. + expect(screen.getByText('••••••••3f9a')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' })) + + expect(await screen.findByText('Ollama takes no API key.')).toBeInTheDocument() + expect(screen.queryByText(/An API key is stored/)).not.toBeInTheDocument() + expect(provider()).toHaveValue('ollama') + fireEvent.click(save()) + expect(await screen.findByText('Changes saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }, + { + request: 'PATCH /api/v1/settings/ai', + body: { provider: 'ollama', base_url: 'http://localhost:11434', model: 'llama3.2' }, + }, + ]) + }) + + it('replaces the key in a sheet, then verifies it', async () => { + const verifiedAt = '2026-10-06T14:00:00.000Z' + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }), + 'POST /api/v1/settings/ai/verify': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: verifiedAt }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + const input = within(sheet).getByLabelText('API key') + expect(input).toHaveAttribute('type', 'password') + fireEvent.change(input, { target: { value: 'sk-ant-secret-b7c1' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + + expect(await screen.findByText(`Verified · ${formatVerifiedDate(verifiedAt)}`)).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByText('••••••••b7c1')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } }, + { request: 'POST /api/v1/settings/ai/verify', body: undefined }, + ]) + expect(document.body).not.toHaveTextContent('sk-ant-secret-b7c1') + }) + + it('still verifies the key when the sheet is dismissed while it is being stored', async () => { + let answerPut: (response: Response) => void = () => { + throw new Error('PUT not sent') + } + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => new Promise((resolve) => (answerPut = resolve)), + 'POST /api/v1/settings/ai/verify': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: '2026-10-06T14:00:00.000Z' }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-ant-secret-b7c1' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + await waitFor(() => expect(within(sheet).getByRole('button', { name: 'Saving…' })).toBeDisabled()) + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled() + + act(() => answerPut(jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }))) + expect(await screen.findByText('Verified · Oct 6')).toBeInTheDocument() + expect(screen.getByText('••••••••b7c1')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Replace' })).toBeEnabled() + expect(sent(fetchMock)).toEqual([ + { request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } }, + { request: 'POST /api/v1/settings/ai/verify', body: undefined }, + ]) + }) + + it('shows the verify error and verifies again on request', async () => { + let verifyCalls = 0 + stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }), + 'POST /api/v1/settings/ai/verify': () => { + verifyCalls++ + return verifyCalls === 1 + ? errorResponse(502, 'auth_failed') + : jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1' }) + }, + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-wrong-b7c1' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Verification failed: the provider rejected this key.') + expect(screen.queryByText(/^Verified ·/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(await screen.findByText('Verified · Oct 1')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('shows a refused key in the sheet', async () => { + stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(400, { + error: { code: 'invalid_settings', message: 'api_key: must be at least 8 characters', retryable: false }, + }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + const sheet = screen.getByRole('dialog', { name: 'Replace API key' }) + fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'short' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' })) + expect(await within(sheet).findByRole('alert')).toHaveTextContent( + 'The key was not stored: api_key: must be at least 8 characters', + ) + }) + + it('removes the stored key from the sheet', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/ai/api-key': () => + jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' })) + expect(await screen.findByText('No key stored')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }]) + }) + + it('resets the system prompt after confirming', async () => { + const fetchMock = stubAPI({ + 'POST /api/v1/settings/ai/reset-prompt': () => jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }), + }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' }) + fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' })) + expect(sent(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Reset prompt' })) + await waitFor(() => expect(prompt()).toHaveValue(DEFAULT_PROMPT)) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/ai/reset-prompt', body: undefined }]) + expect(save()).toBeDisabled() + }) + + it('asks before leaving with unsaved changes', async () => { + stubAPI() + await renderPage() + fireEvent.change(prompt(), { target: { value: 'Unsaved.' } }) + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' }) + fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(prompt()).toHaveValue('Unsaved.') + + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('leaves without asking when nothing changed', async () => { + stubAPI() + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('guards a page unload only while there are unsaved changes', async () => { + stubAPI() + await renderPage() + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }) + act(() => void window.dispatchEvent(event)) + return event.defaultPrevented + } + expect(unload()).toBe(false) + fireEvent.change(slider(), { target: { value: '0.2' } }) + expect(unload()).toBe(true) + }) + + it('keeps 44 px tap targets', async () => { + stubAPI() + await renderPage() + for (const control of [ + screen.getByRole('button', { name: 'Back to Settings' }), + screen.getByRole('button', { name: 'Replace' }), + provider(), + model(), + slider(), + screen.getByRole('button', { name: 'Reset' }), + save(), + ]) { + expectMinHitArea(control) + } + }) +}) + +describe('maskedKey', () => { + it('shows only the last four characters', () => { + expect(maskedKey('3f9a')).toBe('••••••••3f9a') + }) +}) diff --git a/web/src/settings/AISettingsPage.tsx b/web/src/settings/AISettingsPage.tsx new file mode 100644 index 0000000..3aecb69 --- /dev/null +++ b/web/src/settings/AISettingsPage.tsx @@ -0,0 +1,571 @@ +import { ChevronLeft } from 'lucide-react' +import { useEffect, useId, useState, type ReactNode } from 'react' +import { useBlocker, useNavigate } from 'react-router' +import { ApiError } from '../api/index.ts' +import { Button } from '../ui/Button.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Select } from '../ui/Select.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Slider } from '../ui/Slider.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import { + aiKeyPolicy, + aiProviderLabels, + providersWithBaseURL, + useAIModels, + useAISettings, + useResetAIPrompt, + useSaveAISettings, + useSetAIKey, + useVerifyAIKey, + type AIProvider, + type AISettings, + type AISettingsPatch, +} from './api.ts' +import styles from './AISettingsPage.module.css' + +// TYPE_MODEL is the Model dropdown's "Type a model id" option. +const TYPE_MODEL = '\u0000type' + +// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and +// system prompt are edited as a draft and saved together; the API key is +// stored (and verified) on its own, straight away. The client only ever +// sees the key's last four characters. +export function AISettingsPage() { + const settings = useAISettings() + return ( +
    +
    + {settings.isPending && } + {settings.isError && ( + void settings.refetch()}> + AI settings could not load: {settings.error.message} + + )} + {settings.isSuccess && } +
    + ) +} + +function Header() { + const navigate = useNavigate() + return ( +
    + void navigate('/settings')}> + + +
    +

    Settings · Admin

    +

    AI assistant

    +
    +
    + ) +} + +type Draft = { + provider: AIProvider + base_url: string + model: string + temperature: number + system_prompt: string +} + +function draftOf(saved: AISettings): Draft { + return { + provider: saved.provider, + base_url: saved.base_url, + model: saved.model, + temperature: saved.temperature, + system_prompt: saved.system_prompt ?? '', + } +} + +// patchOf is the PATCH body for the fields draft changes. +function patchOf(saved: AISettings, draft: Draft): AISettingsPatch { + const before = draftOf(saved) + const patch: AISettingsPatch = {} + if (draft.provider !== before.provider) patch.provider = draft.provider + if (draft.base_url !== before.base_url) patch.base_url = draft.base_url + if (draft.model !== before.model) patch.model = draft.model + if (draft.temperature !== before.temperature) patch.temperature = draft.temperature + if (draft.system_prompt !== before.system_prompt) patch.system_prompt = draft.system_prompt + return patch +} + +function AISettingsForm({ saved }: { saved: AISettings }) { + const [draft, setDraft] = useState(() => draftOf(saved)) + const [typingModel, setTypingModel] = useState(false) + const [confirmingReset, setConfirmingReset] = useState(false) + const save = useSaveAISettings() + const resetPrompt = useResetAIPrompt() + const patch = patchOf(saved, draft) + const dirty = Object.keys(patch).length > 0 + const providerChanged = draft.provider !== saved.provider + // The server keeps a stored key's provider and base URL fixed until the + // key is removed. + const keyLocked = saved.api_key_last4 !== null && (providerChanged || draft.base_url !== saved.base_url) + const temperatureId = useId() + // The model list is the saved provider's, so it is asked for only while + // that provider is chosen and has the key it needs. + const canList = + !providerChanged && (aiKeyPolicy[saved.provider] !== 'required' || saved.api_key_last4 !== null) + const models = useAIModels(canList) + const listed = canList && models.isSuccess ? models.data : [] + const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model)) + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname, + ) + useEffect(() => { + if (!dirty) return + const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault() + window.addEventListener('beforeunload', onBeforeUnload) + return () => window.removeEventListener('beforeunload', onBeforeUnload) + }, [dirty]) + + function edit(change: Partial) { + save.reset() + setDraft((current) => ({ ...current, ...change })) + } + + function chooseProvider(provider: AIProvider) { + setTypingModel(provider !== saved.provider) + if (provider === saved.provider) { + edit({ provider, base_url: saved.base_url, model: saved.model }) + return + } + // A fixed-URL preset refuses any other base URL, and the old model is + // another provider's: the household types the new one. + edit({ provider, base_url: providersWithBaseURL.has(provider) ? draft.base_url : '', model: '' }) + } + + function onSave() { + save.mutate(patch, { + onSuccess: (settings) => { + setDraft(draftOf(settings)) + setTypingModel(false) + }, + }) + } + + function onConfirmReset() { + resetPrompt.mutate(undefined, { + onSuccess: (settings) => { + setDraft((current) => ({ ...current, system_prompt: settings.system_prompt ?? '' })) + setConfirmingReset(false) + }, + }) + } + + return ( + <> +
    + +
    + + {canList && models.isPending &&

    Loading models…

    } + {canList && models.isError &&

    Model list unavailable: {models.error.message}

    } + {!canList && !providerChanged && ( +

    Add the API key to choose from the provider's models.

    + )} +
    +
    + {keyLocked &&

    {keyLockedGuidance}

    } + {typedModel && ( + edit({ model: event.target.value })} + /> + )} + {providersWithBaseURL.has(draft.provider) && ( + edit({ base_url: event.target.value })} + /> + )} + + +
    +
    + + Creativity (temperature) + + + {draft.temperature.toFixed(1)} + +
    + edit({ temperature: Math.round(Number(event.target.value) * 10) / 10 })} + /> + +
    + + edit({ system_prompt })} /> + +
    + {save.isSuccess && !dirty && 'Changes saved.'} +
    + {save.isError &&

    {saveErrorMessage(save.error)}

    } + {resetPrompt.isError && !confirmingReset && ( +

    + The system prompt was not reset: {resetPrompt.error.message} +

    + )} + +
    + + +
    + + setConfirmingReset(false)} + onConfirm={onConfirmReset} + > + This restores MealQuest's default system prompt for everyone in the household. + + blocker.reset?.()} + onConfirm={() => blocker.proceed?.()} + > + Your changes to the AI assistant have not been saved. + + + ) +} + +// maskedKey shows the stored key as dots and its last four characters, all +// the client ever knows of it. +export function maskedKey(last4: string): string { + return `••••••••${last4}` +} + +// formatVerifiedDate turns key_verified_at into the handoff's "Oct 1". +export function formatVerifiedDate(timestamp: string): string { + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) throw new Error(`key_verified_at "${timestamp}" is not a timestamp`) + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date) +} + +// keyLockedGuidance is how to change the provider or base URL while a key +// is stored. +const keyLockedGuidance = + 'An API key is stored. Remove it (Replace → Remove key) before changing the provider or base URL.' + +// APIKey stays mounted whatever provider the draft names, so a key stored +// (and the verify that follows it) completes even if the household changes +// the provider meanwhile, and a stored key can always be removed. +function APIKey({ saved, draftProvider }: { saved: AISettings; draftProvider: AIProvider }) { + const [replacing, setReplacing] = useState(false) + const [key, setKey] = useState('') + const store = useSetAIKey() + const verify = useVerifyAIKey() + const labelId = useId() + const sheetTitleId = useId() + const last4 = saved.api_key_last4 + const providerChanged = draftProvider !== saved.provider + + function open() { + store.reset() + setReplacing(true) + } + + // close keeps the store mutation: a key already sent is still stored and + // verified after the sheet is dismissed. + function close() { + setReplacing(false) + setKey('') + } + + function onStore() { + verify.reset() + store.mutate(key, { + onSuccess: () => { + close() + verify.mutate(undefined) + }, + }) + } + + function onRemove() { + verify.reset() + store.mutate('', { onSuccess: close }) + } + + const storeError = store.isError && ( +

    + The key was not stored: {store.error.message} +

    + ) + + if (last4 === null && aiKeyPolicy[draftProvider] === 'none') { + return ( +
    +

    {aiProviderLabels[draftProvider]} takes no API key.

    + {!replacing && storeError} +
    + ) + } + + return ( +
    + + API key + +
    + {last4 === null ? 'No key stored' : maskedKey(last4)} + +
    + {last4 === null && providerChanged && ( +

    Save the provider change before adding its key.

    + )} + {!replacing && storeError} + + + +
    { + event.preventDefault() + onStore() + }} + > +

    + {last4 === null ? 'Add API key' : 'Replace API key'} +

    +

    + The key is stored on the server for {aiProviderLabels[saved.provider]}. This device only ever sees its + last four characters. +

    + setKey(event.target.value)} + /> + {storeError} +
    + {last4 !== null && ( + + )} + +
    + +
    +
    + ) +} + +type KeyStatusProps = { + saved: AISettings + verify: ReturnType +} + +// KeyStatus is the line under the key: verifying, the verify error, when it +// was verified, or that it has not been. +function KeyStatus({ saved, verify }: KeyStatusProps) { + if (saved.api_key_last4 === null) return null + if (verify.isPending) return

    Verifying…

    + if (verify.isError) { + return ( +
    +

    + {verifyErrorMessage(verify.error)} +

    + Try again +
    + ) + } + if (saved.key_verified_at !== null) { + return

    Verified · {formatVerifiedDate(saved.key_verified_at)}

    + } + return ( +
    +

    Not verified yet

    + Verify +
    + ) +} + +function VerifyButton({ verify, children }: { verify: KeyStatusProps['verify']; children: ReactNode }) { + return ( + + ) +} + +// verifyErrorMessage says why POST /settings/ai/verify failed, by the +// error code the server names (internal/server/aisettings.go). +export function verifyErrorMessage(error: Error): string { + if (!(error instanceof ApiError)) return `Verification failed: ${error.message}` + switch (error.code) { + case 'auth_failed': + return 'Verification failed: the provider rejected this key.' + case 'rate_limited': + return 'Verification failed: the provider is rate limiting requests. Try again shortly.' + case 'timeout': + return 'Verification failed: the provider did not answer in time.' + case 'settings_changed': + return 'Verification failed: the settings changed while verifying. Try again.' + default: + return `Verification failed: ${error.message}` + } +} + +// saveErrorMessage says why a save failed. The server refuses to move a +// stored key to another provider or base URL; it names how to fix that. +function saveErrorMessage(error: Error): string { + if (error instanceof ApiError && error.code === 'invalid_settings') { + const guidance = error.message.includes('while an API key is stored') ? ` ${keyLockedGuidance}` : '' + return `Not saved: ${error.message}.${guidance}` + } + return `Not saved: ${error.message}` +} + +function PromptField({ value, onChange }: { value: string; onChange: (value: string) => void }) { + const id = useId() + return ( +
    + +