From 713c502e57660b0c43022a541d3b024379aa23af Mon Sep 17 00:00:00 2001 From: Joncallim Date: Mon, 22 Jun 2026 12:03:13 +0000 Subject: [PATCH 1/2] Add provider catalog, local model discovery, and plan revisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider configuration - Add web/lib/providers/catalog.ts as the single source of truth for each provider (local/remote/cloud category, key/base-URL requirements, default base URL, API-key link). The setup UI and runtime registry both read from it. - Refactor the Providers and Agents dashboard pages around the catalog so adding a provider is a one-file change. - Update provider registry/types to consume the catalog. Local model discovery - POST /api/providers/discover-local probes locally running Ollama and LM Studio, registers any installed models not already configured, and returns what was discovered/added. Probes are best-effort with short timeouts so a missing local runtime never blocks the request. Plan revisions - POST /api/tasks/:id/replan requests a revised plan for a task awaiting approval: the reviewer's feedback is appended to the prompt as a delimited revision note and the task is re-queued for the architect stage. - Surface a replan action on the task detail page. Uninstall - Drop the Forge application database named in DATABASE_URL on --remove-data, and discover local project folders from both .forge/project-paths and the projects table so web-UI-created projects are removed too. Tests/docs - Extend API tests for the new endpoints (69 passing). - Update install/uninstall and shipping-roadmap docs. - Ignore local .env files and .claude/worktrees scratch checkouts. Verification: npm run lint, npx tsc --noEmit, npm test (69), npm run build — all pass. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 8 + docs/install-uninstall.md | 14 +- docs/shipping-roadmap.md | 18 ++ scripts/uninstall.sh | 85 +++++- web/__tests__/api.test.ts | 90 +++++- web/app/api/providers/discover-local/route.ts | 106 +++++++ web/app/api/tasks/[id]/replan/route.ts | 112 +++++++ web/app/dashboard/agents/page.tsx | 75 ++++- web/app/dashboard/providers/page.tsx | 278 ++++++++---------- web/app/dashboard/tasks/[id]/page.tsx | 121 +++++++- web/lib/providers/catalog.ts | 149 ++++++++++ web/lib/providers/registry.ts | 19 +- web/lib/providers/types.ts | 22 +- 13 files changed, 903 insertions(+), 194 deletions(-) create mode 100644 web/app/api/providers/discover-local/route.ts create mode 100644 web/app/api/tasks/[id]/replan/route.ts create mode 100644 web/lib/providers/catalog.ts diff --git a/.gitignore b/.gitignore index df54670..133b1b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ .DS_Store .forge/ + +# Local agent worktrees (scratch checkouts created during development) +.claude/worktrees/ + +# Local environment / secrets — the installer writes secrets to ./.env +.env +.env.* +!.env.example diff --git a/docs/install-uninstall.md b/docs/install-uninstall.md index 7139d74..8235d76 100644 --- a/docs/install-uninstall.md +++ b/docs/install-uninstall.md @@ -101,8 +101,11 @@ bash scripts/uninstall.sh --remove-data ``` This removes local build artifacts, recorded Forge-only packages, `.env`, -Docker volumes, recorded PostgreSQL database objects, recorded Ollama models, -and Forge's local install state. +Docker volumes, recorded Ollama models, and Forge's local install state. It also +**drops the Forge application database** named in `DATABASE_URL` (default +`forge`), which clears saved logins, projects, and task history — so a fresh +install starts from a clean login. The drop runs whenever PostgreSQL is reachable, +even if the database existed before Forge installed. By default it does **not** delete the local project folders Forge created. When run interactively it asks whether to delete them, or you can opt in directly: @@ -111,9 +114,10 @@ run interactively it asks whether to delete them, or you can opt in directly: bash scripts/uninstall.sh --remove-data --remove-projects ``` -This deletes every folder listed in `.forge/project-paths` (the local projects -Forge created) along with their files. Use `--keep-projects` to skip the prompt -and always keep them. +This deletes every local project folder Forge created, along with their files. +Folders are discovered both from `.forge/project-paths` and from the `projects` +table in the database (so projects created through the web UI are removed too). +Use `--keep-projects` to skip the prompt and always keep them. It still does not remove Homebrew, Linux package managers, Docker Desktop/Engine, packages that existed before Forge, or recorded packages that diff --git a/docs/shipping-roadmap.md b/docs/shipping-roadmap.md index 9022576..9d81a25 100644 --- a/docs/shipping-roadmap.md +++ b/docs/shipping-roadmap.md @@ -155,6 +155,24 @@ npm run build # pass 5. Add permission checks around project/task access once multi-user behavior is productized beyond local operator usage. +## GitHub Authentication + +GitHub authentication must happen **in the web UI, and only when the `gh` CLI is +not already authenticated** (detected via `gh auth status`, the same probe used in +`web/app/api/health/route.ts`). When the CLI is already logged in, Forge uses that +token and never prompts. + +- **Phase 1 — Personal Access Token (PAT).** When the CLI is not authenticated, the + web UI prompts for a PAT, validates it against `GET https://api.github.com/user`, + and stores it encrypted via `web/lib/crypto.ts` (the same mechanism as provider + keys). Token resolution order for repo operations: stored PAT → `gh` CLI token → + legacy per-project `githubTokenEnvVar`. Tracked in + [issue #12](https://github.com/Joncallim/Forge/issues/12). +- **Phase 2 — GitHub OAuth (device flow).** Register a GitHub OAuth app and run the + device-code flow in the web UI so the user authorizes Forge without creating a PAT + by hand; store the resulting token encrypted. This is the preferred end state once + an OAuth app is registered; the PAT path remains as a fallback for headless setups. + ## P2 Autonomous Coding Stage 1. Add a specialist subagent registry and harness model. Each harness should diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index bc584bb..49853c5 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -114,6 +114,46 @@ project_paths() { fi } +# Read a key from the first .env file that defines it. The .env files still +# exist at this point because remove_env_files runs near the end of uninstall. +read_env_var() { + local key="$1" file value + for file in "$REPO_ROOT/.env" "$REPO_ROOT/.env.local" "$REPO_ROOT/web/.env" "$REPO_ROOT/web/.env.local"; do + [ -f "$file" ] || continue + value="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n1)" || true + if [ -n "$value" ]; then + value="${value#"${key}="}" + value="${value%\"}"; value="${value#\"}" + value="${value%\'}"; value="${value#\'}" + printf '%s' "$value" + return 0 + fi + done +} + +app_database_url() { + read_env_var DATABASE_URL +} + +# Local project folders recorded in the database. This catches projects created +# through the web UI, which the on-disk registry (.forge/project-paths) does not +# always record. Best-effort: silent when psql or the database is unavailable. +db_project_paths() { + local url + url="$(app_database_url)" + [ -n "$url" ] || return 0 + command -v psql >/dev/null 2>&1 || return 0 + psql "$url" -tAc \ + "SELECT local_path FROM projects WHERE local_path IS NOT NULL AND local_path <> ''" \ + 2>/dev/null | grep -v '^[[:space:]]*$' || true +} + +# Union of folders from the on-disk registry and the database, de-duplicated +# while preserving order. +all_project_paths() { + { project_paths; db_project_paths; } | grep -v '^[[:space:]]*$' | awk '!seen[$0]++' +} + resolve_remove_projects() { [ -n "$REMOVE_PROJECTS" ] && return 0 @@ -122,7 +162,7 @@ resolve_remove_projects() { return 0 fi - if [ -z "$(project_paths)" ]; then + if [ -z "$(all_project_paths)" ]; then REMOVE_PROJECTS=0 return 0 fi @@ -131,7 +171,7 @@ resolve_remove_projects() { info "Forge created these local project folders:" while IFS= read -r project_dir; do [ -n "$project_dir" ] && info " - $project_dir" - done < <(project_paths) + done < <(all_project_paths) printf " Delete all of these project folders and their files? [y/N] " read -r answer || answer="" case "$answer" in @@ -142,7 +182,7 @@ resolve_remove_projects() { remove_project_files() { [ "$REMOVE_PROJECTS" = "1" ] || return 0 - [ -n "$(project_paths)" ] || return 0 + [ -n "$(all_project_paths)" ] || return 0 step "Removing local project folders" while IFS= read -r project_dir; do @@ -156,7 +196,7 @@ remove_project_files() { else rm -rf "$project_dir" && info "Removed $project_dir" || warn "Could not remove $project_dir" fi - done < <(project_paths) + done < <(all_project_paths) if [ "$DRY_RUN" != "1" ]; then rm -f "$PROJECT_PATHS_FILE" 2>/dev/null || true @@ -316,6 +356,41 @@ drop_recorded_postgres_data() { fi } +# Drop the Forge application database named in DATABASE_URL (default: forge), +# even when the installer did not record creating it (e.g. a reused database). +# This is what actually removes saved logins, projects, and task history on +# --remove-data. The recorded role/database cleanup still runs afterwards via +# drop_recorded_postgres_data, which uses the local maintenance connection. +drop_app_database() { + [ "$KEEP_DATA" = "0" ] || return 0 + + local url dbname admin_url + url="$(app_database_url)" + if [ -n "$url" ]; then + dbname="${url##*/}" + dbname="${dbname%%\?*}" + admin_url="${url%/*}/postgres" + if [ -n "$dbname" ]; then + step "Removing the Forge application database" + if [ "$DRY_RUN" = "1" ]; then + info "[dry-run] Drop database $dbname (removes saved logins, projects, and history)" + elif ! command -v psql >/dev/null 2>&1; then + info "psql is not installed, so database $dbname was left in place. Saved logins remain." + elif ! psql "$admin_url" -tAc 'SELECT 1' >/dev/null 2>&1; then + info "PostgreSQL is not reachable, so the database was left in place. Saved logins and projects remain." + elif psql "$admin_url" -c "DROP DATABASE IF EXISTS \"$dbname\" WITH (FORCE);" >/dev/null 2>&1; then + info "Dropped database $dbname (removed saved logins, projects, and task history)." + else + info "Database $dbname could not be dropped now, so it was left in place." + fi + fi + fi + + # Recorded role/database cleanup via the local maintenance connection. + # Idempotent with the URL-based drop above. + drop_recorded_postgres_data +} + stop_docker_services() { step "Stopping Docker Compose services" if ! command -v docker >/dev/null 2>&1; then @@ -644,7 +719,7 @@ fi remove_project_files remove_build_artifacts -drop_recorded_postgres_data +drop_app_database stop_docker_services remove_recorded_ollama_models remove_recorded_packages diff --git a/web/__tests__/api.test.ts b/web/__tests__/api.test.ts index 079d706..074d096 100644 --- a/web/__tests__/api.test.ts +++ b/web/__tests__/api.test.ts @@ -329,6 +329,81 @@ describe('POST /api/tasks/:id/approve — 409 when status is pending', () => { }) }) +// --------------------------------------------------------------------------- +// Suite 3.4b — Change plan: POST /api/tasks/:id/replan +// --------------------------------------------------------------------------- + +describe('POST /api/tasks/:id/replan', () => { + beforeEach(() => { vi.clearAllMocks() }) + + it('returns 409 when task is not awaiting approval', async () => { + mockGetSession.mockResolvedValue(FAKE_SESSION) + mockDbSelect.mockReturnValue(chain([{ id: 'task-9', status: 'pending', prompt: 'do x' }])) + + const { POST } = await import('@/app/api/tasks/[id]/replan/route') + const req = authRequest('/api/tasks/task-9/replan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feedback: 'tweak it' }), + }) + const res = await POST(req as never, { params: Promise.resolve({ id: 'task-9' }) }) + + expect(res.status).toBe(409) + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('returns 400 when feedback is missing', async () => { + mockGetSession.mockResolvedValue(FAKE_SESSION) + mockDbSelect.mockReturnValue(chain([{ id: 'task-9', status: 'awaiting_approval', prompt: 'do x' }])) + + const { POST } = await import('@/app/api/tasks/[id]/replan/route') + const req = authRequest('/api/tasks/task-9/replan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const res = await POST(req as never, { params: Promise.resolve({ id: 'task-9' }) }) + + expect(res.status).toBe(400) + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('re-queues the task and appends feedback to the prompt on success', async () => { + mockGetSession.mockResolvedValue(FAKE_SESSION) + mockDbSelect.mockReturnValue(chain([{ id: 'task-9', status: 'awaiting_approval', prompt: 'do x' }])) + mockDbUpdate.mockReturnValue(chain([{ id: 'task-9', status: 'pending', updatedAt: new Date() }])) + + const { POST } = await import('@/app/api/tasks/[id]/replan/route') + const req = authRequest('/api/tasks/task-9/replan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feedback: 'use a queue instead' }), + }) + const res = await POST(req as never, { params: Promise.resolve({ id: 'task-9' }) }) + + expect(res.status).toBe(200) + expect(mockDbUpdate).toHaveBeenCalled() + expect(mockRedisLpush).toHaveBeenCalledWith('forge:tasks', expect.stringContaining('task-9')) + }) +}) + +// --------------------------------------------------------------------------- +// Suite 3.4c — Local discovery: POST /api/providers/discover-local auth guard +// --------------------------------------------------------------------------- + +describe('POST /api/providers/discover-local — auth guard', () => { + beforeEach(() => { vi.clearAllMocks() }) + + it('returns 401 when not authenticated', async () => { + mockGetSession.mockResolvedValue(null) + + const { POST } = await import('@/app/api/providers/discover-local/route') + const res = await POST(authRequest('/api/providers/discover-local', { method: 'POST' }) as never) + + expect(res.status).toBe(401) + }) +}) + // --------------------------------------------------------------------------- // Suite 3.5 — Agent type sanitisation: PUT /api/agents/../../etc/passwd returns 400 // @@ -362,14 +437,15 @@ describe('PUT /api/agents/[type] — path traversal blocked', () => { }) // --------------------------------------------------------------------------- -// Suite 3.6 — Provider validation: POST /api/providers with providerType='ollama' -// and no baseUrl returns 400 +// Suite 3.6 — Provider validation: baseUrl is required only for self-hosted +// endpoints (custom/litellm). Local runtimes like ollama default to +// a known localhost URL, so no baseUrl is required. // --------------------------------------------------------------------------- -describe('POST /api/providers — baseUrl required for ollama', () => { +describe('POST /api/providers — baseUrl requirement', () => { beforeEach(() => { vi.clearAllMocks() }) - it('returns 400 when providerType is ollama and baseUrl is missing', async () => { + it('allows ollama without baseUrl (defaults to the local endpoint)', async () => { mockGetSession.mockResolvedValue(FAKE_SESSION) const { POST } = await import('@/app/api/providers/route') @@ -381,15 +457,13 @@ describe('POST /api/providers — baseUrl required for ollama', () => { providerType: 'ollama', modelId: 'llama3', isLocal: true, - // no baseUrl + // no baseUrl — ollama defaults to http://localhost:11434 }), }) const res = await POST(req as never) - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error).toMatch(/baseUrl/i) + expect(res.status).toBe(201) }) it('returns 400 when providerType is custom and baseUrl is missing', async () => { diff --git a/web/app/api/providers/discover-local/route.ts b/web/app/api/providers/discover-local/route.ts new file mode 100644 index 0000000..46dd9df --- /dev/null +++ b/web/app/api/providers/discover-local/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { db } from '@/db' +import { providerConfigs } from '@/db/schema' +import { and, eq } from 'drizzle-orm' +import { getSession } from '@/lib/session' +import { PROVIDER_CATALOG } from '@/lib/providers/catalog' + +// --------------------------------------------------------------------------- +// POST /api/providers/discover-local +// +// Probes locally-running Ollama and LM Studio installations for installed models +// and registers any that are not already configured as providers. Returns what +// was discovered and what was added. Probes are best-effort with short timeouts +// so a missing local runtime never blocks the request. +// --------------------------------------------------------------------------- + +const OLLAMA_BASE_URL = PROVIDER_CATALOG.ollama.defaultBaseUrl ?? 'http://localhost:11434' +const LMSTUDIO_BASE_URL = PROVIDER_CATALOG.lmstudio.defaultBaseUrl ?? 'http://localhost:1234/v1' +const PROBE_TIMEOUT_MS = 1500 + +type DiscoveredModel = { + providerType: 'ollama' | 'lmstudio' + modelId: string + baseUrl: string +} + +async function fetchJson(url: string): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) + try { + const res = await fetch(url, { signal: controller.signal }) + if (!res.ok) return null + return await res.json() + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +async function discoverOllama(): Promise { + const data = await fetchJson(`${OLLAMA_BASE_URL}/api/tags`) + const models = (data as { models?: { name?: string }[] } | null)?.models ?? [] + return models + .map((m) => m.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0) + .map((modelId) => ({ providerType: 'ollama' as const, modelId, baseUrl: OLLAMA_BASE_URL })) +} + +async function discoverLmStudio(): Promise { + const data = await fetchJson(`${LMSTUDIO_BASE_URL}/models`) + const models = (data as { data?: { id?: string }[] } | null)?.data ?? [] + return models + .map((m) => m.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + .map((modelId) => ({ providerType: 'lmstudio' as const, modelId, baseUrl: LMSTUDIO_BASE_URL })) +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(request) + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const [ollama, lmstudio] = await Promise.all([discoverOllama(), discoverLmStudio()]) + const discovered = [...ollama, ...lmstudio] + + const added: { providerType: string; modelId: string }[] = [] + for (const model of discovered) { + // Skip if a provider of the same type already serves this exact model id. + const existing = await db + .select({ id: providerConfigs.id }) + .from(providerConfigs) + .where( + and( + eq(providerConfigs.providerType, model.providerType), + eq(providerConfigs.modelId, model.modelId), + ), + ) + .limit(1) + + if (existing.length > 0) continue + + await db.insert(providerConfigs).values({ + displayName: `${model.providerType === 'ollama' ? 'Ollama' : 'LM Studio'}: ${model.modelId}`, + providerType: model.providerType, + modelId: model.modelId, + baseUrl: model.baseUrl, + isLocal: true, + }) + added.push({ providerType: model.providerType, modelId: model.modelId }) + } + + return NextResponse.json({ + found: discovered.length, + added, + ollamaReachable: ollama.length > 0, + lmstudioReachable: lmstudio.length > 0, + }) + } catch (err) { + console.error('[POST /api/providers/discover-local] Unexpected error', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/web/app/api/tasks/[id]/replan/route.ts b/web/app/api/tasks/[id]/replan/route.ts new file mode 100644 index 0000000..5b6830c --- /dev/null +++ b/web/app/api/tasks/[id]/replan/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { db } from '@/db' +import { tasks } from '@/db/schema' +import { and, eq } from 'drizzle-orm' +import { getSession } from '@/lib/session' +import { redis } from '@/lib/redis' + +// --------------------------------------------------------------------------- +// Validation schema +// --------------------------------------------------------------------------- + +const replanSchema = z.object({ + feedback: z.string().trim().min(1, 'Feedback is required to change the plan'), +}) + +// --------------------------------------------------------------------------- +// POST /api/tasks/:id/replan +// +// Requests a revised plan for a task awaiting approval. The reviewer's feedback +// is appended to the task prompt as a clearly delimited revision note so the +// orchestrator re-plans with the full, stored history, and the task is re-queued +// for the architect stage (status -> pending). +// --------------------------------------------------------------------------- + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const session = await getSession(request) + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: taskId } = await params + + const [existing] = await db + .select() + .from(tasks) + .where(eq(tasks.id, taskId)) + .limit(1) + + if (!existing) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }) + } + + if (existing.status !== 'awaiting_approval') { + return NextResponse.json( + { error: `Cannot change the plan for a task with status '${existing.status}'. Task must be in 'awaiting_approval' status.` }, + { status: 409 }, + ) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = replanSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', issues: parsed.error.issues }, + { status: 400 }, + ) + } + + const { feedback } = parsed.data + const revisionNote = [ + '', + '', + '---', + `## Plan revision requested (${new Date().toISOString()})`, + feedback, + ].join('\n') + + const [task] = await db + .update(tasks) + .set({ + prompt: existing.prompt + revisionNote, + status: 'pending', + errorMessage: null, + updatedAt: new Date(), + }) + .where(and(eq(tasks.id, taskId), eq(tasks.status, 'awaiting_approval'))) + .returning() + + if (!task) { + return NextResponse.json( + { error: `Cannot change the plan for a task with status '${existing.status}'. Task must be in 'awaiting_approval' status.` }, + { status: 409 }, + ) + } + + // Re-queue for the architect stage, the same way new tasks are enqueued. + await redis.lpush('forge:tasks', JSON.stringify({ taskId: task.id })) + await redis.publish('forge:task:' + taskId, JSON.stringify({ + type: 'task:status', + status: 'pending', + updatedAt: task.updatedAt.toISOString(), + })) + + console.info('[POST /api/tasks/:id/replan] Re-queued task for revised plan', { id: taskId }) + return NextResponse.json({ task }) + } catch (err) { + console.error('[POST /api/tasks/:id/replan] Unexpected error', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/web/app/dashboard/agents/page.tsx b/web/app/dashboard/agents/page.tsx index 797de8d..f4b3928 100644 --- a/web/app/dashboard/agents/page.tsx +++ b/web/app/dashboard/agents/page.tsx @@ -22,7 +22,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { ROLE_RECOMMENDATIONS, type RoleRecommendation } from '@/lib/recommendations' +import { PRESETS, ROLE_RECOMMENDATIONS, type RoleRecommendation } from '@/lib/recommendations' +import { applyPreset } from '@/lib/applyPreset' import { PROVIDER_TYPE_LABELS, PROVIDER_TYPE_OPTIONS, @@ -533,6 +534,8 @@ export default function AgentsPage() { const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState(null) const [editingAgent, setEditingAgent] = useState(null) + const [applyingPreset, setApplyingPreset] = useState(null) + const [presetError, setPresetError] = useState(null) // --------------------------------------------------------------------------- // Load data @@ -599,6 +602,27 @@ export default function AgentsPage() { [providers], ) + // --------------------------------------------------------------------------- + // Apply recommended configuration (preset) — configures providers + agents + // --------------------------------------------------------------------------- + + async function handleApplyPreset(presetId: string) { + const preset = PRESETS.find((p) => p.id === presetId) + if (!preset) return + + setApplyingPreset(presetId) + setPresetError(null) + + try { + await applyPreset(preset) + await loadData() + } catch (err) { + setPresetError(err instanceof Error ? err.message : 'Failed to apply preset') + } finally { + setApplyingPreset(null) + } + } + // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- @@ -697,6 +721,55 @@ export default function AgentsPage() { )} + {/* Recommended configurations — apply a vetted provider + agent setup */} + {!loading && fetchError === null && ( +
+

+ Recommended configurations +

+

+ Apply a vetted setup to configure providers and assign a model to every agent + in one step. +

+ {presetError !== null && ( +
+ {presetError} +
+ )} +
+ {PRESETS.map((preset) => ( +
+
+ {preset.label} +

+ {preset.description} +

+
+

+ {preset.estimatedMonthlyCost} +

+ +
+ ))} +
+
+ )} + {/* Edit drawer */} // Constants // --------------------------------------------------------------------------- -const MODEL_PLACEHOLDERS: Record = { - anthropic: 'claude-opus-4-8', - openai: 'gpt-4.1', - google: 'gemini-2.0-flash', - openrouter: 'moonshotai/kimi-k2', - ollama: 'devstral-small:24b', - litellm: 'litellm/claude-opus-4-8', - custom: 'gpt-5.5 or provider/model', -} - -const PROVIDER_TYPE_COLORS: Record = { - anthropic: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300', - openai: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', - google: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', - openrouter: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', - ollama: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', - litellm: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', - custom: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300', +const CATEGORY_COLORS: Record = { + local: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + remote: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', + cloud: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300', } // --------------------------------------------------------------------------- @@ -94,7 +83,6 @@ type ProviderFormState = { modelId: string baseUrl: string apiKey: string - apiKeyEnvVar: string isLocal: boolean } @@ -104,7 +92,6 @@ const DEFAULT_FORM: ProviderFormState = { modelId: '', baseUrl: '', apiKey: '', - apiKeyEnvVar: '', isLocal: false, } @@ -115,7 +102,6 @@ function formFromProvider(p: ProviderConfig): ProviderFormState { modelId: p.modelId, baseUrl: p.baseUrl ?? '', apiKey: '', // never prefilled — the stored secret is never sent to the client - apiKeyEnvVar: p.apiKeyEnvVar ?? '', isLocal: p.isLocal, } } @@ -190,8 +176,11 @@ interface ProviderFormProps { } function ProviderForm({ form, onChange, error, submitting, onSubmit, submitLabel, keyAlreadySet = false }: ProviderFormProps) { - const needsBaseUrl = requiresProviderBaseUrl(form.providerType) - const needsApiKey = !form.isLocal + const entry = PROVIDER_CATALOG[form.providerType] + const needsApiKey = entry.requiresApiKey + const showBaseUrl = entry.requiresBaseUrl || entry.category === 'local' + const baseUrlRequired = entry.requiresBaseUrl + const category = providerCategory(form.providerType, form.isLocal) function set(key: K, value: ProviderFormState[K]) { onChange({ ...form, [key]: value }) @@ -200,8 +189,14 @@ function ProviderForm({ form, onChange, error, submitting, onSubmit, submitLabel function handleProviderTypeChange(value: string | null) { if (!value) return const pt = value as ProviderType - const isLocal = pt === 'ollama' - onChange({ ...form, providerType: pt, isLocal }) + const next = PROVIDER_CATALOG[pt] + onChange({ + ...form, + providerType: pt, + isLocal: next.category === 'local', + // Suggest the known base URL for the newly chosen provider. + baseUrl: next.defaultBaseUrl ?? '', + }) } return ( @@ -226,7 +221,7 @@ function ProviderForm({ form, onChange, error, submitting, onSubmit, submitLabel {/* Provider type */}
+

+ {PROVIDER_CATEGORY_LABELS[category]} + {entry.helpText ? ` — ${entry.helpText}` : ''} +

{/* Model ID */} @@ -255,27 +254,30 @@ function ProviderForm({ form, onChange, error, submitting, onSubmit, submitLabel required value={form.modelId} onChange={(e) => set('modelId', e.target.value)} - placeholder={MODEL_PLACEHOLDERS[form.providerType]} + placeholder={entry.modelPlaceholder} className="rounded-lg border border-input bg-transparent px-3 py-2 font-mono text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" aria-required="true" /> - {/* Base URL — shown for provider types that require an OpenAI-compatible endpoint */} - {needsBaseUrl && ( + {/* Base URL — for self-hosted endpoints (required) and local runtimes (optional override) */} + {showBaseUrl && (
set('baseUrl', e.target.value)} - placeholder={form.providerType === 'ollama' ? 'http://localhost:11434' : 'https://api.example.com/v1'} + placeholder={entry.defaultBaseUrl ?? 'https://api.example.com/v1'} className="rounded-lg border border-input bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" - aria-required="true" + aria-required={baseUrlRequired} />
)} @@ -296,50 +298,24 @@ function ProviderForm({ form, onChange, error, submitting, onSubmit, submitLabel className="rounded-lg border border-input bg-transparent px-3 py-2 font-mono text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" />

- Stored encrypted in the database. You only enter this once a provider needs it. + Stored encrypted in the database. + {entry.apiKeyUrl && ( + <> + {' '} + + Get a {PROVIDER_TYPE_LABELS[form.providerType]} API key → + + + )}

)} - {/* Advanced: read the key from an environment variable instead */} - {needsApiKey && ( -
- - Advanced: use an environment variable instead - -
- - set('apiKeyEnvVar', e.target.value)} - placeholder="ANTHROPIC_API_KEY" - className="rounded-lg border border-input bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" - /> -

- Fallback used only when no key is entered above. The stored key takes precedence. -

-
-
- )} - - {/* Is local toggle */} -
- set('isLocal', e.target.checked)} - className="size-4 rounded border-input accent-primary" - /> - -
- {error !== null && (

{error} @@ -414,8 +390,8 @@ export default function ProvidersPage() { const [fetchError, setFetchError] = useState(null) const [healthMap, setHealthMap] = useState({}) const [deletingId, setDeletingId] = useState(null) - const [applyingPreset, setApplyingPreset] = useState(null) - const [presetError, setPresetError] = useState(null) + const [discovering, setDiscovering] = useState(false) + const [discoverMsg, setDiscoverMsg] = useState(null) // Add dialog const [addOpen, setAddOpen] = useState(false) @@ -495,15 +471,15 @@ export default function ProvidersPage() { e.preventDefault() setAddError(null) + const entry = PROVIDER_CATALOG[addForm.providerType] const displayName = addForm.displayName.trim() const modelId = addForm.modelId.trim() - const baseUrl = addForm.baseUrl.trim() || null - const apiKeyEnvVar = addForm.isLocal ? null : addForm.apiKeyEnvVar.trim() || null - const apiKey = addForm.isLocal ? null : addForm.apiKey.trim() || null + const baseUrl = addForm.baseUrl.trim() || entry.defaultBaseUrl || null + const apiKey = entry.requiresApiKey ? addForm.apiKey.trim() || null : null if (!displayName) { setAddError('Display name is required.'); return } if (!modelId) { setAddError('Model ID is required.'); return } - if (requiresProviderBaseUrl(addForm.providerType) && !baseUrl) { + if (entry.requiresBaseUrl && !baseUrl) { setAddError('Base URL is required for this provider type.') return } @@ -518,7 +494,6 @@ export default function ProvidersPage() { providerType: addForm.providerType, modelId, baseUrl, - apiKeyEnvVar, apiKey, isLocal: addForm.isLocal, }), @@ -551,16 +526,16 @@ export default function ProvidersPage() { if (!editProvider) return setEditError(null) + const entry = PROVIDER_CATALOG[editForm.providerType] const displayName = editForm.displayName.trim() const modelId = editForm.modelId.trim() - const baseUrl = editForm.baseUrl.trim() || null - const apiKeyEnvVar = editForm.isLocal ? null : editForm.apiKeyEnvVar.trim() || null + const baseUrl = editForm.baseUrl.trim() || entry.defaultBaseUrl || null // Only send apiKey when the user typed one; a blank field keeps the stored key. - const typedApiKey = editForm.isLocal ? '' : editForm.apiKey.trim() + const typedApiKey = entry.requiresApiKey ? editForm.apiKey.trim() : '' if (!displayName) { setEditError('Display name is required.'); return } if (!modelId) { setEditError('Model ID is required.'); return } - if (requiresProviderBaseUrl(editForm.providerType) && !baseUrl) { + if (entry.requiresBaseUrl && !baseUrl) { setEditError('Base URL is required for this provider type.') return } @@ -575,7 +550,6 @@ export default function ProvidersPage() { providerType: editForm.providerType, modelId, baseUrl, - apiKeyEnvVar, ...(typedApiKey !== '' ? { apiKey: typedApiKey } : {}), isLocal: editForm.isLocal, }), @@ -615,23 +589,38 @@ export default function ProvidersPage() { } // --------------------------------------------------------------------------- - // Apply preset + // Detect local models (Ollama / LM Studio) // --------------------------------------------------------------------------- - async function handleApplyPreset(presetId: string) { - const preset = PRESETS.find((p) => p.id === presetId) - if (!preset) return - - setApplyingPreset(presetId) - setPresetError(null) - + async function handleDiscoverLocal() { + setDiscovering(true) + setDiscoverMsg(null) try { - await applyPreset(preset) - await loadProviders() + const res = await fetch('/api/providers/discover-local', { method: 'POST' }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error((body as { error?: string }).error ?? 'Local detection failed') + } + const data = await res.json() as { + found: number + added: { providerType: string; modelId: string }[] + ollamaReachable: boolean + lmstudioReachable: boolean + } + if (data.added.length > 0) { + setDiscoverMsg(`Added ${data.added.length} local model${data.added.length === 1 ? '' : 's'}.`) + await loadProviders() + } else if (data.found > 0) { + setDiscoverMsg('Local models found are already configured.') + } else if (!data.ollamaReachable && !data.lmstudioReachable) { + setDiscoverMsg('No running Ollama or LM Studio detected on localhost.') + } else { + setDiscoverMsg('No local models found.') + } } catch (err) { - setPresetError(err instanceof Error ? err.message : 'Failed to apply preset') + setDiscoverMsg(err instanceof Error ? err.message : 'Local detection failed') } finally { - setApplyingPreset(null) + setDiscovering(false) } } @@ -644,6 +633,17 @@ export default function ProvidersPage() { {/* Page header */}

Providers

+
+ +
+ {/* Discovery feedback */} + {discoverMsg !== null && ( +

+ {discoverMsg} +

+ )} + {/* Loading */} {loading && (
@@ -696,7 +704,8 @@ export default function ProvidersPage() { {!loading && fetchError === null && providers.length === 0 && (

- No providers configured yet. Add one or apply a preset below. + No providers configured yet. Add one above, or apply a recommended + configuration from the Agents page.

)} @@ -708,9 +717,9 @@ export default function ProvidersPage() { Display name - Type + Provider Model ID - Source + Category Health Actions @@ -724,10 +733,8 @@ export default function ProvidersPage() { {provider.displayName} - - {provider.providerType} + + {PROVIDER_TYPE_LABELS[provider.providerType] ?? provider.providerType} @@ -739,9 +746,16 @@ export default function ProvidersPage() { - - {provider.isLocal ? 'Local' : 'Cloud'} - + {(() => { + const category = providerCategory(provider.providerType, provider.isLocal) + return ( + + {PROVIDER_CATEGORY_LABELS[category]} + + ) + })()} @@ -812,48 +826,6 @@ export default function ProvidersPage() {
)} - {/* Recommended configurations */} -
-

- Recommended configurations -

- {presetError !== null && ( -
- {presetError} -
- )} -
- {PRESETS.map((preset) => ( -
-
- {preset.label} -

- {preset.description} -

-
-

- {preset.estimatedMonthlyCost} -

- -
- ))} -
-
) } diff --git a/web/app/dashboard/tasks/[id]/page.tsx b/web/app/dashboard/tasks/[id]/page.tsx index fafdc40..d87a2b9 100644 --- a/web/app/dashboard/tasks/[id]/page.tsx +++ b/web/app/dashboard/tasks/[id]/page.tsx @@ -289,11 +289,12 @@ export default function TaskDetailPage() { const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState(null) - // Approve / reject state + // Approve / change-plan / restart state const [actionLoading, setActionLoading] = useState(false) const [actionError, setActionError] = useState(null) - const [showRejectForm, setShowRejectForm] = useState(false) + const [actionMode, setActionMode] = useState<'none' | 'restart' | 'replan'>('none') const [rejectReason, setRejectReason] = useState('') + const [replanFeedback, setReplanFeedback] = useState('') // SSE stream const { runs: streamRuns, artifacts: streamArtifacts, taskStatus, error: streamError } = useTaskStream(taskId) @@ -365,9 +366,9 @@ export default function TaskDetailPage() { }) if (!res.ok) { const body = await res.json().catch(() => ({})) - throw new Error(body.error ?? 'Failed to reject task') + throw new Error(body.error ?? 'Failed to restart task') } - setShowRejectForm(false) + setActionMode('none') setRejectReason('') await loadTask() } catch (err) { @@ -377,6 +378,35 @@ export default function TaskDetailPage() { } } + async function handleReplan(e: React.FormEvent) { + e.preventDefault() + const feedback = replanFeedback.trim() + if (!feedback) { + setActionError('Describe what to change before requesting a revised plan.') + return + } + setActionLoading(true) + setActionError(null) + try { + const res = await fetch(`/api/tasks/${taskId}/replan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feedback }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error ?? 'Failed to request a revised plan') + } + setActionMode('none') + setReplanFeedback('') + await loadTask() + } catch (err) { + setActionError(err instanceof Error ? err.message : 'An unexpected error occurred') + } finally { + setActionLoading(false) + } + } + if (loading) { return (
@@ -474,11 +504,22 @@ export default function TaskDetailPage() { )}
- {/* Approve / Reject actions */} + {/* Task prompt — always shown, so the originating instruction is visible */} +
+

+ Prompt +

+
+ +
+
+ + {/* Approve / Change plan / Restart actions */} {isAwaitingApproval && (

- Review the generated plan before closing this Orchestrator stage. + Review the generated plan. You can approve it, ask for a revised plan, or + restart the task.

{actionError !== null && ( @@ -487,7 +528,7 @@ export default function TaskDetailPage() {

)} - {!showRejectForm ? ( + {actionMode === 'none' && (
+
- ) : ( + )} + + {actionMode === 'replan' && ( +
+
+ +