-
Notifications
You must be signed in to change notification settings - Fork 125
feat: model selection settings, Apple Silicon Vexor acceleration, and worktree sync fixes #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5c5091e
41fdb44
64ffada
f3b75e7
72bb230
eb69d47
4e0a8eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| /** | ||
| * SettingsRoutes | ||
| * | ||
| * API endpoints for reading and writing model preferences from ~/.pilot/config.json. | ||
| * | ||
| * GET /api/settings - Returns current model config with defaults merged in | ||
| * PUT /api/settings - Partial update of model preferences (merge, not replace) | ||
| */ | ||
|
|
||
| import express, { type Request, type Response } from "express"; | ||
| import * as fs from "fs"; | ||
| import * as os from "os"; | ||
| import * as path from "path"; | ||
| import { BaseRouteHandler } from "../BaseRouteHandler.js"; | ||
| import { logger } from "../../../../utils/logger.js"; | ||
|
|
||
| export const MODEL_CHOICES_FULL: readonly string[] = ["sonnet", "sonnet[1m]", "opus", "opus[1m]"]; | ||
| export const MODEL_CHOICES_AGENT: readonly string[] = ["sonnet", "opus"]; | ||
|
|
||
| export interface ModelSettings { | ||
| model: string; | ||
| commands: Record<string, string>; | ||
| agents: Record<string, string>; | ||
| } | ||
|
|
||
| export const DEFAULT_SETTINGS: ModelSettings = { | ||
| model: "opus", | ||
| commands: { | ||
| spec: "sonnet", | ||
| "spec-plan": "opus", | ||
| "spec-implement": "sonnet", | ||
| "spec-verify": "opus", | ||
| vault: "sonnet", | ||
| sync: "sonnet", | ||
| learn: "sonnet", | ||
| }, | ||
| agents: { | ||
| "plan-challenger": "sonnet", | ||
| "plan-verifier": "sonnet", | ||
| "spec-reviewer-compliance": "sonnet", | ||
| "spec-reviewer-quality": "opus", | ||
| }, | ||
| }; | ||
|
|
||
| export class SettingsRoutes extends BaseRouteHandler { | ||
| private readonly configPath: string; | ||
|
|
||
| constructor(configPath?: string) { | ||
| super(); | ||
| this.configPath = configPath ?? path.join(os.homedir(), ".pilot", "config.json"); | ||
| } | ||
|
|
||
| setupRoutes(app: express.Application): void { | ||
| app.get("/api/settings", this.wrapHandler(this.handleGet.bind(this))); | ||
| app.put("/api/settings", this.wrapHandler(this.handlePut.bind(this))); | ||
| } | ||
|
|
||
| private readConfig(): Record<string, unknown> { | ||
| try { | ||
| const raw = fs.readFileSync(this.configPath, "utf-8"); | ||
| return JSON.parse(raw) as Record<string, unknown>; | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
|
|
||
| private mergeWithDefaults(raw: Record<string, unknown>): ModelSettings { | ||
| const mainModel = | ||
| typeof raw.model === "string" && MODEL_CHOICES_FULL.includes(raw.model) | ||
| ? raw.model | ||
| : DEFAULT_SETTINGS.model; | ||
|
|
||
| const rawCommands = raw.commands; | ||
| const mergedCommands: Record<string, string> = { ...DEFAULT_SETTINGS.commands }; | ||
| if (rawCommands && typeof rawCommands === "object" && !Array.isArray(rawCommands)) { | ||
| for (const [k, v] of Object.entries(rawCommands as Record<string, unknown>)) { | ||
| if (typeof v === "string" && MODEL_CHOICES_FULL.includes(v)) { | ||
| mergedCommands[k] = v; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const rawAgents = raw.agents; | ||
| const mergedAgents: Record<string, string> = { ...DEFAULT_SETTINGS.agents }; | ||
| if (rawAgents && typeof rawAgents === "object" && !Array.isArray(rawAgents)) { | ||
| for (const [k, v] of Object.entries(rawAgents as Record<string, unknown>)) { | ||
| if (typeof v === "string" && MODEL_CHOICES_AGENT.includes(v)) { | ||
| mergedAgents[k] = v; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return { model: mainModel, commands: mergedCommands, agents: mergedAgents }; | ||
| } | ||
|
|
||
| private validateSettings(body: Record<string, unknown>): string | null { | ||
| if (body.model !== undefined) { | ||
| if (typeof body.model !== "string" || !MODEL_CHOICES_FULL.includes(body.model)) { | ||
| return `Invalid model '${body.model}'; must be one of: ${MODEL_CHOICES_FULL.join(", ")}`; | ||
| } | ||
| } | ||
|
|
||
| if (body.commands !== undefined) { | ||
| if (typeof body.commands !== "object" || Array.isArray(body.commands)) { | ||
| return "commands must be an object"; | ||
| } | ||
| for (const [cmd, model] of Object.entries(body.commands as Record<string, unknown>)) { | ||
| if (typeof model !== "string" || !MODEL_CHOICES_FULL.includes(model)) { | ||
| return `Invalid model '${model}' for command '${cmd}'; must be one of: ${MODEL_CHOICES_FULL.join(", ")}`; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (body.agents !== undefined) { | ||
| if (typeof body.agents !== "object" || Array.isArray(body.agents)) { | ||
| return "agents must be an object"; | ||
| } | ||
| for (const [agent, model] of Object.entries(body.agents as Record<string, unknown>)) { | ||
| if (typeof model !== "string" || !MODEL_CHOICES_AGENT.includes(model)) { | ||
| return `Invalid model '${model}' for agent '${agent}'; agents can only use: ${MODEL_CHOICES_AGENT.join(", ")} (no 1M context)`; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private writeConfigAtomic(data: Record<string, unknown>): void { | ||
| const dir = path.dirname(this.configPath); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| const tmpPath = this.configPath + ".tmp"; | ||
| fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8"); | ||
| fs.renameSync(tmpPath, this.configPath); | ||
| } | ||
|
|
||
| async handleGet(_req: Request, res: Response): Promise<void> { | ||
| const raw = this.readConfig(); | ||
| const settings = this.mergeWithDefaults(raw); | ||
| res.json(settings); | ||
| } | ||
|
|
||
| async handlePut(req: Request, res: Response): Promise<void> { | ||
| const body = req.body as Record<string, unknown>; | ||
|
|
||
| const error = this.validateSettings(body); | ||
| if (error) { | ||
| this.badRequest(res, error); | ||
| return; | ||
| } | ||
|
|
||
| const existing = this.readConfig(); | ||
|
|
||
| if (body.model !== undefined) { | ||
| existing.model = body.model; | ||
| } | ||
| if (body.commands !== undefined) { | ||
| const existingCommands = (existing.commands as Record<string, unknown>) ?? {}; | ||
| existing.commands = { ...existingCommands, ...(body.commands as Record<string, unknown>) }; | ||
| } | ||
| if (body.agents !== undefined) { | ||
| const existingAgents = (existing.agents as Record<string, unknown>) ?? {}; | ||
| existing.agents = { ...existingAgents, ...(body.agents as Record<string, unknown>) }; | ||
| } | ||
|
|
||
| try { | ||
| this.writeConfigAtomic(existing); | ||
| } catch (err) { | ||
| logger.error("HTTP", "Failed to write settings config", {}, err as Error); | ||
| res.status(500).json({ error: "Failed to save settings" }); | ||
| return; | ||
| } | ||
|
|
||
| const updated = this.mergeWithDefaults(existing); | ||
| res.json(updated); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { useState, useCallback, useEffect } from 'react'; | ||
|
|
||
| export const MODEL_CHOICES_FULL = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const; | ||
| export const MODEL_CHOICES_AGENT = ['sonnet', 'opus'] as const; | ||
|
|
||
| export type ModelFull = (typeof MODEL_CHOICES_FULL)[number]; | ||
| export type ModelAgent = (typeof MODEL_CHOICES_AGENT)[number]; | ||
|
|
||
| export const MODEL_DISPLAY_NAMES: Record<string, string> = { | ||
| sonnet: 'Sonnet 4.6', | ||
| 'sonnet[1m]': 'Sonnet 4.6 1M', | ||
| opus: 'Opus 4.6', | ||
| 'opus[1m]': 'Opus 4.6 1M', | ||
| }; | ||
|
|
||
| export interface ModelSettings { | ||
| model: string; | ||
| commands: Record<string, string>; | ||
| agents: Record<string, string>; | ||
| } | ||
|
|
||
| export const DEFAULT_SETTINGS: ModelSettings = { | ||
| model: 'opus', | ||
| commands: { | ||
| spec: 'sonnet', | ||
| 'spec-plan': 'opus', | ||
| 'spec-implement': 'sonnet', | ||
| 'spec-verify': 'opus', | ||
| vault: 'sonnet', | ||
| sync: 'sonnet', | ||
| learn: 'sonnet', | ||
| }, | ||
| agents: { | ||
| 'plan-challenger': 'sonnet', | ||
| 'plan-verifier': 'sonnet', | ||
| 'spec-reviewer-compliance': 'sonnet', | ||
| 'spec-reviewer-quality': 'opus', | ||
| }, | ||
| }; | ||
|
|
||
| export interface UseSettingsResult { | ||
| settings: ModelSettings; | ||
| isLoading: boolean; | ||
| error: string | null; | ||
| isDirty: boolean; | ||
| saved: boolean; | ||
| updateModel: (model: string) => void; | ||
| updateCommand: (command: string, model: string) => void; | ||
| updateAgent: (agent: string, model: string) => void; | ||
| save: () => Promise<void>; | ||
| } | ||
|
|
||
| export function useSettings(): UseSettingsResult { | ||
| const [settings, setSettings] = useState<ModelSettings>(DEFAULT_SETTINGS); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [isDirty, setIsDirty] = useState(false); | ||
| const [saved, setSaved] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| fetch('/api/settings') | ||
| .then((r) => { | ||
| if (!r.ok) throw new Error(`API error: ${r.status}`); | ||
| return r.json(); | ||
| }) | ||
| .then((data: ModelSettings) => { | ||
| setSettings(data); | ||
| setIsLoading(false); | ||
| }) | ||
| .catch((err: Error) => { | ||
| setError(err.message || 'Failed to load settings'); | ||
| setIsLoading(false); | ||
| }); | ||
| }, []); | ||
|
|
||
| const updateModel = useCallback((model: string) => { | ||
| setSettings((prev) => ({ ...prev, model })); | ||
| setIsDirty(true); | ||
| setSaved(false); | ||
| }, []); | ||
|
|
||
| const updateCommand = useCallback((command: string, model: string) => { | ||
| setSettings((prev) => ({ | ||
| ...prev, | ||
| commands: { ...prev.commands, [command]: model }, | ||
| })); | ||
| setIsDirty(true); | ||
| setSaved(false); | ||
| }, []); | ||
|
|
||
| const updateAgent = useCallback((agent: string, model: string) => { | ||
| setSettings((prev) => ({ | ||
| ...prev, | ||
| agents: { ...prev.agents, [agent]: model }, | ||
| })); | ||
| setIsDirty(true); | ||
| setSaved(false); | ||
| }, []); | ||
|
|
||
| const save = useCallback(async () => { | ||
| await fetch('/api/settings', { | ||
| method: 'PUT', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(settings), | ||
| }).then((r) => { | ||
| if (!r.ok) throw new Error(`Save failed: ${r.status}`); | ||
| return r.json(); | ||
| }).then((data: ModelSettings) => { | ||
| setSettings(data); | ||
| setIsDirty(false); | ||
| setSaved(true); | ||
| }); | ||
| }, [settings]); | ||
|
|
||
| return { settings, isLoading, error, isDirty, saved, updateModel, updateCommand, updateAgent, save }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import React from 'react'; | ||
| import { MODEL_DISPLAY_NAMES } from '../../hooks/useSettings.js'; | ||
|
|
||
| interface ModelSelectProps { | ||
| value: string; | ||
| choices: readonly string[]; | ||
| onChange: (model: string) => void; | ||
| disabled?: boolean; | ||
| id?: string; | ||
| } | ||
|
Comment on lines
+4
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add
♻️ Proposed fix interface ModelSelectProps {
value: string;
choices: readonly string[];
onChange: (model: string) => void;
disabled?: boolean;
id?: string;
+ ariaLabel?: string;
}
-export function ModelSelect({ value, choices, onChange, disabled = false, id }: ModelSelectProps) {
+export function ModelSelect({ value, choices, onChange, disabled = false, id, ariaLabel }: ModelSelectProps) {
return (
<select
id={id}
+ aria-label={ariaLabel}🤖 Prompt for AI Agents |
||
|
|
||
| export function ModelSelect({ value, choices, onChange, disabled = false, id }: ModelSelectProps) { | ||
| return ( | ||
| <select | ||
| id={id} | ||
| className="select select-sm select-bordered w-full max-w-xs" | ||
| value={value} | ||
| onChange={(e) => onChange(e.target.value)} | ||
| disabled={disabled} | ||
| > | ||
| {choices.map((model) => ( | ||
| <option key={model} value={model}> | ||
| {MODEL_DISPLAY_NAMES[model] ?? model} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
save()errors are not surfaced through the hook'serrorstate — callers must catch the rejected Promise.On a failed PUT (network error or
!r.ok), the error propagates as a rejected Promise butsetError()is never called. A call-site like<button onClick={() => save()}>will produce an unhandled rejection with no UI feedback. Either expose asaveErrorstate, or catch internally and updateerror:♻️ Proposed fix (expose saveError or update error on failure)
Remember to add
saveError: string | nulltoUseSettingsResult.🤖 Prompt for AI Agents