From 5660c976676fe166c07b8d0f5e961fa32ab24c3f Mon Sep 17 00:00:00 2001 From: botlong Date: Sun, 24 May 2026 15:14:03 +0800 Subject: [PATCH] Add agent-scoped model settings --- gateway/src/agents/claude_code.js | 9 +- gateway/src/agents/codex.js | 46 +- gateway/src/agents/opencode.js | 10 +- gateway/src/config.js | 106 +- gateway/src/server.js | 54 +- gateway/test/agent_settings.test.js | 178 ++ gateway/test/codex_models.test.js | 62 + gateway/test/opencode_profile_env.test.js | 28 + lib/api/gateway_client.dart | 58 +- lib/state/agent_catalog_store.dart | 4 +- lib/state/agent_config_filter.dart | 57 + lib/state/gateway_session_store.dart | 18 +- lib/state/settings_store.dart | 128 +- lib/ui/pages/agent_group_page.dart | 59 +- lib/ui/pages/settings_page.dart | 2562 ++++++++++----------- test/state/agent_config_filter_test.dart | 42 + test/state/settings_store_test.dart | 51 + 17 files changed, 2024 insertions(+), 1448 deletions(-) create mode 100644 gateway/test/agent_settings.test.js create mode 100644 gateway/test/codex_models.test.js create mode 100644 gateway/test/opencode_profile_env.test.js create mode 100644 lib/state/agent_config_filter.dart create mode 100644 test/state/agent_config_filter_test.dart create mode 100644 test/state/settings_store_test.dart diff --git a/gateway/src/agents/claude_code.js b/gateway/src/agents/claude_code.js index 0c430cd..a2b0f7f 100644 --- a/gateway/src/agents/claude_code.js +++ b/gateway/src/agents/claude_code.js @@ -61,11 +61,12 @@ class ClaudeCodeAdapter { }; } - models() { - return cachedModels("claude-code", () => this._fetchModels()); + models(options = {}) { + const profileId = options.profileId || 'none'; + return cachedModels(`claude-code:${profileId}`, () => this._fetchModels(options)); } - async _fetchModels() { + async _fetchModels(options = {}) { const envModels = (process.env.CLAUDE_CODE_MODELS || '') .split(',') .map((value) => value.trim()) @@ -74,7 +75,7 @@ class ClaudeCodeAdapter { return envModels.map((id) => ({ id, displayName: id, raw: { id } })); } - const profileKey = this.profileStore?.getKeyForProvider('anthropic'); + const profileKey = this.profileStore?.getKeyForProviderById(options.profileId, 'anthropic'); const apiKey = profileKey?.key || null; const baseUrl = profileKey?.baseUrl || null; if (apiKey) { diff --git a/gateway/src/agents/codex.js b/gateway/src/agents/codex.js index 6ab9a82..5642a16 100644 --- a/gateway/src/agents/codex.js +++ b/gateway/src/agents/codex.js @@ -76,11 +76,39 @@ class CodexAdapter { }; } - models() { - return cachedModels('codex', () => this._fetchModels()); + models(options = {}) { + const profileId = options.profileId || 'none'; + return cachedModels(`codex:${profileId}`, () => this._fetchModels(options)); } - async _fetchModels() { + async _fetchModels(options = {}) { + const profileKey = this.profileStore?.getKeyForProviderById( + options.profileId, 'openai'); + if (profileKey?.key) { + try { + const res = await fetch(openAIModelsUrl(profileKey.baseUrl), { + headers: { + authorization: `Bearer ${profileKey.key}`, + accept: 'application/json', + }, + signal: AbortSignal.timeout(8000), + }); + if (res.ok) { + const body = await res.json(); + const models = readOpenAIModels(body) + .filter((model) => model.id) + .map((model) => ({ + id: model.id, + displayName: model.display_name || model.name || model.id, + raw: model, + })); + if (models.length > 0) return models; + } + } catch (err) { + console.warn(`[codex] Failed to fetch models from OpenAI profile: ${err.message}`); + } + } + const result = await runCapture(this.command, ['debug', 'models', '--bundled']); if (result.exitCode === 0) { try { @@ -170,6 +198,18 @@ function compactCodexModel(model) { }; } +function openAIModelsUrl(baseUrl) { + const root = (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, ''); + return `${root}/models`; +} + +function readOpenAIModels(body) { + if (Array.isArray(body)) return body; + if (Array.isArray(body?.data)) return body.data; + if (Array.isArray(body?.models)) return body.models; + return []; +} + module.exports = { CodexAdapter, CODEX_COMMANDS, diff --git a/gateway/src/agents/opencode.js b/gateway/src/agents/opencode.js index 3751c89..5c9f729 100644 --- a/gateway/src/agents/opencode.js +++ b/gateway/src/agents/opencode.js @@ -82,8 +82,9 @@ class OpenCodeAdapter { }; } - models() { - return cachedModels("opencode", () => this._fetchModels()); + models(options = {}) { + const profileId = options.profileId || 'none'; + return cachedModels(`opencode:${profileId}`, () => this._fetchModels(options)); } async _fetchModels() { @@ -287,6 +288,11 @@ class OpenCodeAdapter { extraEnv.OPENAI_API_KEY = openaiKey.key; if (openaiKey.baseUrl) extraEnv.OPENAI_BASE_URL = openaiKey.baseUrl; } + const opencodeKey = this.profileStore?.getKeyForProviderById(profileId, 'opencode'); + if (opencodeKey?.key && !extraEnv.OPENAI_API_KEY) { + extraEnv.OPENAI_API_KEY = opencodeKey.key; + if (opencodeKey.baseUrl) extraEnv.OPENAI_BASE_URL = opencodeKey.baseUrl; + } const googleKey = this.profileStore?.getKeyForProviderById(profileId, 'google'); if (googleKey?.key) { extraEnv.GOOGLE_API_KEY = googleKey.key; diff --git a/gateway/src/config.js b/gateway/src/config.js index c3e071a..94119eb 100644 --- a/gateway/src/config.js +++ b/gateway/src/config.js @@ -40,6 +40,7 @@ class ProfileStore { constructor(file) { this.file = file || DEFAULT_PROFILES_PATH; this.profiles = []; + this.agentSettings = { agents: {} }; this.saveQueue = Promise.resolve(); } @@ -52,13 +53,24 @@ class ProfileStore { try { const raw = await fs.readFile(this.file, 'utf8'); const parsed = JSON.parse(raw); - this.profiles = Array.isArray(parsed) ? parsed : []; + if (Array.isArray(parsed)) { + this.profiles = parsed; + this.agentSettings = { agents: {} }; + } else if (parsed && typeof parsed === 'object') { + this.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : []; + this.agentSettings = normalizeAgentSettings(parsed.agentSettings); + } else { + this.profiles = []; + this.agentSettings = { agents: {} }; + } } catch (error) { if (error.code === 'ENOENT') { this.profiles = []; + this.agentSettings = { agents: {} }; await this.save(); } else if (error instanceof SyntaxError) { this.profiles = []; + this.agentSettings = { agents: {} }; await this.save(); } else { throw error; @@ -99,7 +111,7 @@ class ProfileStore { * Create a new profile. If it's the first profile, it becomes active * automatically. */ - async create({ name, keys, defaultModel }) { + async create({ name, keys }) { if (!name || typeof name !== 'string') { throw Object.assign(new Error('Profile name is required'), { statusCode: 400 }); } @@ -110,7 +122,6 @@ class ProfileStore { name, isCurrent: isFirst, keys: keys && typeof keys === 'object' ? keys : {}, - defaultModel: defaultModel && typeof defaultModel === 'object' ? defaultModel : {}, createdAt: Date.now(), }; @@ -142,10 +153,6 @@ class ProfileStore { }; } } - if (patch.defaultModel !== undefined && typeof patch.defaultModel === 'object') { - profile.defaultModel = patch.defaultModel; - } - await this.save(); return profile; } @@ -203,14 +210,57 @@ class ProfileStore { return { key: entry.key || null, baseUrl: entry.baseUrl || null }; } - /** - * From the active profile, return the default model ID for a given agent. - * Returns null if no active profile or agent not configured. - */ getDefaultModel(agentId) { - const active = this.getActive(); - if (!active || !active.defaultModel) return null; - return active.defaultModel[agentId] || null; + return this.getAgentSettings(agentId).defaultModel; + } + + getAgentProfileId(agentId) { + return this.getAgentSettings(agentId).profileId; + } + + getAgentSettings(agentId) { + const settings = this.agentSettings.agents[agentId] || {}; + return { + profileId: settings.profileId || null, + defaultModel: settings.defaultModel || null, + }; + } + + listAgentSettings(agentIds = []) { + const ids = new Set([ + ...agentIds, + ...Object.keys(this.agentSettings.agents || {}), + ]); + return [...ids].map((agentId) => ({ + agentId, + ...this.getAgentSettings(agentId), + })); + } + + async updateAgentSettings(agentId, patch) { + if (!agentId || typeof agentId !== 'string') { + throw Object.assign(new Error('agentId is required'), { statusCode: 400 }); + } + const current = this.getAgentSettings(agentId); + const next = { ...current }; + if (Object.prototype.hasOwnProperty.call(patch, 'profileId')) { + if (patch.profileId !== null && typeof patch.profileId !== 'string') { + throw Object.assign(new Error('profileId must be a string or null'), { statusCode: 400 }); + } + if (patch.profileId && !this.get(patch.profileId)) { + throw Object.assign(new Error('profile not found'), { statusCode: 404 }); + } + next.profileId = patch.profileId || null; + } + if (Object.prototype.hasOwnProperty.call(patch, 'defaultModel')) { + if (patch.defaultModel !== null && typeof patch.defaultModel !== 'string') { + throw Object.assign(new Error('defaultModel must be a string or null'), { statusCode: 400 }); + } + next.defaultModel = patch.defaultModel || null; + } + this.agentSettings.agents[agentId] = next; + await this.save(); + return this.getAgentSettings(agentId); } /** @@ -231,7 +281,18 @@ class ProfileStore { await fs.mkdir(path.dirname(this.file), { recursive: true }); const temp = `${this.file}.${process.pid}.${crypto.randomUUID()}.tmp`; try { - await fs.writeFile(temp, `${JSON.stringify(this.profiles, null, 2)}\n`, 'utf8'); + await fs.writeFile( + temp, + `${JSON.stringify( + { + profiles: this.profiles, + agentSettings: this.agentSettings, + }, + null, + 2, + )}\n`, + 'utf8', + ); await fs.rename(temp, this.file); } catch (error) { // Clean up temp file on failure. @@ -241,4 +302,19 @@ class ProfileStore { } } +function normalizeAgentSettings(value) { + const settings = { agents: {} }; + const agents = value && typeof value === 'object' && value.agents && typeof value.agents === 'object' + ? value.agents + : {}; + for (const [agentId, entry] of Object.entries(agents)) { + if (!entry || typeof entry !== 'object') continue; + settings.agents[agentId] = { + profileId: typeof entry.profileId === 'string' ? entry.profileId : null, + defaultModel: typeof entry.defaultModel === 'string' ? entry.defaultModel : null, + }; + } + return settings; +} + module.exports = { ProfileStore, maskProfile }; diff --git a/gateway/src/server.js b/gateway/src/server.js index aa0de6f..2338bb3 100644 --- a/gateway/src/server.js +++ b/gateway/src/server.js @@ -99,6 +99,7 @@ async function createGatewayServer({ dataFile, adapters, profilesFile } = {}) { url, store, registry, + profileStore, }); } @@ -179,6 +180,7 @@ async function createGatewayServer({ dataFile, adapters, profilesFile } = {}) { response, segments, profileStore, + registry, }); } @@ -238,13 +240,20 @@ async function handleProjects({ request, response, segments, store, registry, pr const body = await readJson(request); const adapter = registry.get(body.agentId); if (!adapter) throw httpError(400, `unknown agent: ${body.agentId}`); + const agentSettings = profileStore.getAgentSettings(body.agentId); + const profileId = body.profileId || agentSettings.profileId; + if (profileId && !profileStore.get(profileId)) { + throw httpError(404, 'profile not found'); + } + const modelId = body.modelId || agentSettings.defaultModel || null; let nativeSession = null; if (adapter.createSession) { try { nativeSession = await adapter.createSession({ project, - modelId: body.modelId, + modelId, title: body.title || `${adapter.displayName} session`, + profileId, }); } catch (_) { nativeSession = null; @@ -253,12 +262,11 @@ async function handleProjects({ request, response, segments, store, registry, pr const rawExtra = {}; if (body.sandbox) rawExtra.sandbox = body.sandbox; if (body.permissionMode) rawExtra.permissionMode = body.permissionMode; - const activeProfile = profileStore.getActive(); - if (activeProfile) rawExtra.profileId = activeProfile.id; + if (profileId) rawExtra.profileId = profileId; const session = await store.createSession({ project, agentId: body.agentId, - modelId: body.modelId, + modelId, title: body.title || nativeSession?.title || `${adapter.displayName} session`, agentSessionId: nativeSession?.agentSessionId, raw: { ...(nativeSession ? { agentSession: nativeSession.raw } : {}), ...rawExtra }, @@ -269,7 +277,7 @@ async function handleProjects({ request, response, segments, store, registry, pr throw httpError(404, 'not found'); } -async function handleAgents({ request, response, segments, url, store, registry }) { +async function handleAgents({ request, response, segments, url, store, registry, profileStore }) { if (segments.length === 1 && request.method === 'GET') { return sendJson(response, await registry.list()); } @@ -279,7 +287,10 @@ async function handleAgents({ request, response, segments, url, store, registry return sendJson(response, await adapter.metadata()); } if (segments.length === 3 && segments[2] === 'models' && request.method === 'GET') { - return sendJson(response, { models: await adapter.models() }); + const requestedProfileId = url.searchParams.get('profileId'); + const profileId = requestedProfileId || profileStore.getAgentProfileId(segments[1]); + if (profileId && !profileStore.get(profileId)) throw httpError(404, 'profile not found'); + return sendJson(response, { models: await adapter.models({ profileId }) }); } if (segments.length === 3 && segments[2] === 'commands' && request.method === 'GET') { const projectId = url.searchParams.get('projectId'); @@ -293,7 +304,7 @@ async function handleAgents({ request, response, segments, url, store, registry throw httpError(404, 'not found'); } -async function handleSettings({ request, response, segments, profileStore }) { +async function handleSettings({ request, response, segments, profileStore, registry }) { // GET /settings/active-profile if (segments.length === 2 && segments[1] === 'active-profile' && request.method === 'GET') { const active = profileStore.getActive(); @@ -375,9 +386,38 @@ async function handleSettings({ request, response, segments, profileStore }) { } } + // /settings/agents routes + if (segments.length >= 2 && segments[1] === 'agents') { + if (segments.length === 2 && request.method === 'GET') { + const agents = (await registry.list()).map((agent) => agent.id); + return sendJson(response, { + agents: profileStore + .listAgentSettings(agents) + .map((setting) => formatAgentSetting(profileStore, setting)), + profiles: profileStore.list(), + }); + } + if (segments.length === 3 && request.method === 'PATCH') { + const agentId = segments[2]; + if (!registry.get(agentId)) throw httpError(404, 'agent not found'); + const updated = await profileStore.updateAgentSettings(agentId, await readJson(request)); + return sendJson(response, formatAgentSetting(profileStore, { agentId, ...updated })); + } + } + throw httpError(404, 'not found'); } +function formatAgentSetting(profileStore, setting) { + const profile = setting.profileId ? profileStore.get(setting.profileId) : null; + return { + agentId: setting.agentId, + profileId: setting.profileId || null, + profile: profile ? maskProfile(profile) : null, + defaultModel: setting.defaultModel || null, + }; +} + async function handleSessions({ request, response, diff --git a/gateway/test/agent_settings.test.js b/gateway/test/agent_settings.test.js new file mode 100644 index 0000000..bd494f5 --- /dev/null +++ b/gateway/test/agent_settings.test.js @@ -0,0 +1,178 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { createGatewayServer } = require('../src/server'); + +test('agent settings bind a profile/default model and apply them to new sessions', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-agent-settings-')); + const dataFile = path.join(root, 'store.json'); + const profilesFile = path.join(root, 'profiles.json'); + const projectDir = path.join(root, 'project'); + await fs.mkdir(projectDir); + + const codex = new RecordingAdapter('codex'); + const server = await createGatewayServer({ + dataFile, + profilesFile, + adapters: new SingleAdapterRegistry(codex), + }); + await listen(server); + t.after(async () => { + server.closeAllRuns?.(); + server.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const base = `http://127.0.0.1:${server.address().port}`; + const profile = await postJson(`${base}/settings/profiles`, { + name: 'Codex Profile', + keys: { + openai: { + key: 'sk-codex-profile-secret', + baseUrl: 'https://example.test/v1', + }, + }, + }); + + const agentSettings = await patchJson(`${base}/settings/agents/codex`, { + profileId: profile.id, + defaultModel: 'gpt-test-default', + }); + assert.equal(agentSettings.agentId, 'codex'); + assert.equal(agentSettings.profile.id, profile.id); + assert.equal(agentSettings.profile.keys.openai.key, 'sk-code...ret'); + assert.equal(agentSettings.defaultModel, 'gpt-test-default'); + + const project = await postJson(`${base}/projects`, { directory: projectDir }); + const session = await postJson(`${base}/projects/${project.id}/sessions`, { + agentId: 'codex', + }); + + assert.equal(session.raw.profileId, profile.id); + assert.equal(session.modelId, 'gpt-test-default'); +}); + +test('agent model listing uses the requested profileId instead of global state', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-agent-models-')); + const dataFile = path.join(root, 'store.json'); + const profilesFile = path.join(root, 'profiles.json'); + + const codex = new RecordingAdapter('codex'); + const server = await createGatewayServer({ + dataFile, + profilesFile, + adapters: new SingleAdapterRegistry(codex), + }); + await listen(server); + t.after(async () => { + server.closeAllRuns?.(); + server.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const base = `http://127.0.0.1:${server.address().port}`; + const profile = await postJson(`${base}/settings/profiles`, { + name: 'Model Profile', + keys: { openai: { key: 'sk-model-profile-secret' } }, + }); + + const listed = await getJson(`${base}/agents/codex/models?profileId=${profile.id}`); + + assert.deepEqual(listed.models, [ + { id: 'model-for-codex', displayName: 'model-for-codex', raw: { profileId: profile.id } }, + ]); + assert.equal(codex.modelCalls.length, 1); + assert.equal(codex.modelCalls[0].profileId, profile.id); +}); + +class SingleAdapterRegistry { + constructor(adapter) { + this.adapter = adapter; + } + + get(agentId) { + return agentId === this.adapter.id ? this.adapter : null; + } + + async list() { + return [await this.adapter.metadata()]; + } +} + +class RecordingAdapter { + constructor(id) { + this.id = id; + this.displayName = id; + this.modelCalls = []; + } + + async metadata() { + return { + id: this.id, + displayName: this.displayName, + supportsModels: true, + supportsSlashCommands: false, + supportsAttachments: false, + supportsPermissions: false, + sessionKind: 'thread', + commands: [], + }; + } + + async models(options = {}) { + this.modelCalls.push(options); + return [ + { + id: `model-for-${this.id}`, + displayName: `model-for-${this.id}`, + raw: { profileId: options.profileId || null }, + }, + ]; + } + + run({ onExit }) { + setImmediate(() => onExit({ exitCode: 0 })); + return { abort() {} }; + } +} + +function listen(server) { + return new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +} + +async function getJson(url) { + const response = await fetch(url); + const text = await response.text(); + assert.equal(response.ok, true, text); + if (!text) return null; + return JSON.parse(text); +} + +async function postJson(url, body) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await response.text(); + assert.equal(response.ok, true, text); + if (!text) return null; + return JSON.parse(text); +} + +async function patchJson(url, body) { + const response = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await response.text(); + assert.equal(response.ok, true, text); + if (!text) return null; + return JSON.parse(text); +} diff --git a/gateway/test/codex_models.test.js b/gateway/test/codex_models.test.js new file mode 100644 index 0000000..9c5263c --- /dev/null +++ b/gateway/test/codex_models.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { CodexAdapter } = require('../src/agents/codex'); + +test('Codex model listing uses the selected OpenAI profile credentials', async (t) => { + const originalFetch = global.fetch; + const calls = []; + global.fetch = async (url, options = {}) => { + calls.push({ url: String(url), headers: options.headers || {} }); + return { + ok: true, + async json() { + return { + data: [ + { + id: 'gpt-profile-model', + object: 'model', + owned_by: 'profile', + }, + { object: 'model' }, + ], + }; + }, + }; + }; + t.after(() => { + global.fetch = originalFetch; + }); + + const adapter = new CodexAdapter({ + profileStore: { + getKeyForProviderById(profileId, provider) { + assert.equal(profileId, 'profile-openai'); + assert.equal(provider, 'openai'); + return { + key: 'sk-profile-secret', + baseUrl: 'https://openai.example/v1/', + }; + }, + }, + }); + + const models = await adapter._fetchModels({ profileId: 'profile-openai' }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://openai.example/v1/models'); + assert.equal(calls[0].headers.authorization, 'Bearer sk-profile-secret'); + assert.deepEqual(models, [ + { + id: 'gpt-profile-model', + displayName: 'gpt-profile-model', + raw: { + id: 'gpt-profile-model', + object: 'model', + owned_by: 'profile', + }, + }, + ]); +}); diff --git a/gateway/test/opencode_profile_env.test.js b/gateway/test/opencode_profile_env.test.js new file mode 100644 index 0000000..b37b84e --- /dev/null +++ b/gateway/test/opencode_profile_env.test.js @@ -0,0 +1,28 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { OpenCodeAdapter } = require('../src/agents/opencode'); + +test('OpenCode maps opencode profile keys to OpenAI-compatible env vars', () => { + const adapter = new OpenCodeAdapter({ + command: 'opencode', + server: { externalBaseUrl: 'http://127.0.0.1:4097' }, + profileStore: { + getKeyForProviderById(profileId, provider) { + assert.equal(profileId, 'profile-opencode'); + if (provider !== 'opencode') return null; + return { + key: 'sk-opencode-secret', + baseUrl: 'https://opencode.example/v1', + }; + }, + }, + }); + + assert.deepEqual(adapter._buildProfileEnv('profile-opencode'), { + OPENAI_API_KEY: 'sk-opencode-secret', + OPENAI_BASE_URL: 'https://opencode.example/v1', + }); +}); diff --git a/lib/api/gateway_client.dart b/lib/api/gateway_client.dart index 75e6ce9..00728c9 100644 --- a/lib/api/gateway_client.dart +++ b/lib/api/gateway_client.dart @@ -114,14 +114,42 @@ class GatewayClient { return Agent.fromJson(res.data ?? const {}); } - Future> listAgentModels(String agentId) async { - final res = await _dio.get('/agents/${_path(agentId)}/models'); + Future> listAgentModels( + String agentId, { + String? profileId, + }) async { + final res = await _dio.get( + '/agents/${_path(agentId)}/models', + queryParameters: { + if (profileId != null && profileId.isNotEmpty) 'profileId': profileId, + }, + ); return _readEnvelopeList(res.data, 'models') .map(AgentModel.fromJson) - .toList(growable: false); - } - - Future> listAgentCommands(String agentId) async { + .toList(growable: false); + } + + Future>> listAgentSettings() async { + final res = await _dio.get('/settings/agents'); + return _readEnvelopeList(res.data, 'agents'); + } + + Future> updateAgentSettings( + String agentId, { + String? profileId, + String? defaultModel, + }) async { + final res = await _dio.patch>( + '/settings/agents/${_path(agentId)}', + data: { + if (profileId != null) 'profileId': profileId, + if (defaultModel != null) 'defaultModel': defaultModel, + }, + ); + return res.data ?? const {}; + } + + Future> listAgentCommands(String agentId) async { final res = await _dio.get('/agents/${_path(agentId)}/commands'); return _readEnvelopeList(res.data, 'commands') .map(AgentCommand.fromJson) @@ -141,10 +169,11 @@ class GatewayClient { required String projectId, required String agentId, String? modelId, - String? title, - String? sandbox, - String? permissionMode, - }) async { + String? title, + String? sandbox, + String? permissionMode, + String? profileId, + }) async { final res = await _dio.post>( '/projects/${_path(projectId)}/sessions', data: { @@ -152,10 +181,11 @@ class GatewayClient { if (modelId != null && modelId.isNotEmpty) 'modelId': modelId, if (title != null && title.isNotEmpty) 'title': title, if (sandbox != null && sandbox.isNotEmpty) 'sandbox': sandbox, - if (permissionMode != null && permissionMode.isNotEmpty) - 'permissionMode': permissionMode, - }, - ); + if (permissionMode != null && permissionMode.isNotEmpty) + 'permissionMode': permissionMode, + if (profileId != null && profileId.isNotEmpty) 'profileId': profileId, + }, + ); return GatewaySession.fromJson(res.data ?? const {}); } diff --git a/lib/state/agent_catalog_store.dart b/lib/state/agent_catalog_store.dart index eebce4c..6860772 100644 --- a/lib/state/agent_catalog_store.dart +++ b/lib/state/agent_catalog_store.dart @@ -99,8 +99,8 @@ class AgentCatalogStore extends StateNotifier { ); } - Future> modelsFor(String agentId) => - _client.listAgentModels(agentId); + Future> modelsFor(String agentId, {String? profileId}) => + _client.listAgentModels(agentId, profileId: profileId); Future> commandsFor(String agentId) => _client.listAgentCommands(agentId); diff --git a/lib/state/agent_config_filter.dart b/lib/state/agent_config_filter.dart new file mode 100644 index 0000000..ca421eb --- /dev/null +++ b/lib/state/agent_config_filter.dart @@ -0,0 +1,57 @@ +library; + +List> credentialEntriesForAgent( + String agentId, + List> entries, +) { + return entries + .where((entry) => credentialEntryMatchesAgent(agentId, entry)) + .toList(growable: false); +} + +bool credentialEntryMatchesAgent( + String agentId, + Map entry, +) { + final appType = credentialEntryAppType(entry); + final providers = credentialEntryProviders(entry); + return switch (agentId) { + 'codex' => appType == 'codex' || providers.contains('openai'), + 'claude-code' => appType == 'claude' || + appType == 'claude-desktop' || + providers.contains('anthropic'), + 'opencode' => appType == 'opencode' || + providers.any( + _opencodeCompatibleProviders.contains, + ), + _ => providers.isNotEmpty, + }; +} + +const _opencodeCompatibleProviders = { + 'opencode', + 'openai', + 'anthropic', + 'google', +}; + +String? credentialEntryAppType(Map entry) { + final raw = entry['raw']; + if (raw is Map && raw['appType'] != null) { + final value = raw['appType'].toString(); + return value.isEmpty ? null : value; + } + return null; +} + +List credentialEntryProviders(Map entry) { + final keys = entry['keys']; + if (keys is Map && keys.isNotEmpty) { + return keys.keys + .map((key) => key.toString()) + .where((key) => key.isNotEmpty) + .toList(growable: false); + } + final provider = entry['provider']?.toString() ?? ''; + return provider.isEmpty ? const [] : [provider]; +} diff --git a/lib/state/gateway_session_store.dart b/lib/state/gateway_session_store.dart index 23a5b57..735171c 100644 --- a/lib/state/gateway_session_store.dart +++ b/lib/state/gateway_session_store.dart @@ -78,17 +78,19 @@ class GatewaySessionStore extends StateNotifier { Future createSession({ required String agentId, - String? modelId, - String? sandbox, - String? permissionMode, - }) async { + String? modelId, + String? sandbox, + String? permissionMode, + String? profileId, + }) async { final session = await _client.createSession( projectId: state.projectId, agentId: agentId, - modelId: modelId, - sandbox: sandbox, - permissionMode: permissionMode, - ); + modelId: modelId, + sandbox: sandbox, + permissionMode: permissionMode, + profileId: profileId, + ); final sessions = [ session, ...state.sessions.where((s) => s.id != session.id), diff --git a/lib/state/settings_store.dart b/lib/state/settings_store.dart index 47008a9..892e718 100644 --- a/lib/state/settings_store.dart +++ b/lib/state/settings_store.dart @@ -2,10 +2,12 @@ /// /// Stored in SharedPreferences so the app remembers them across launches. library; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; @immutable class AppSettings { @@ -15,10 +17,12 @@ class AppSettings { required this.modelId, this.themeMode = ThemeMode.system, this.lastAgentId = '', - this.lastModelId = '', - this.lastSessionId = '', - this.lastProjectId = '', - }); + this.lastModelId = '', + this.lastSessionId = '', + this.lastProjectId = '', + this.selectedProfileByAgent = const {}, + this.defaultModelByAgent = const {}, + }); final String baseUrl; final String providerId; @@ -28,11 +32,14 @@ class AppSettings { /// Last-used preferences for quick restore. final String lastAgentId; final String lastModelId; - final String lastSessionId; - final String lastProjectId; - - bool get isConfigured => - baseUrl.isNotEmpty && providerId.isNotEmpty && modelId.isNotEmpty; + final String lastSessionId; + final String lastProjectId; + + /// Agent-scoped defaults. Profiles and models are no longer global settings. + final Map selectedProfileByAgent; + final Map defaultModelByAgent; + + bool get isConfigured => baseUrl.isNotEmpty; AppSettings copyWith({ String? baseUrl, @@ -40,20 +47,25 @@ class AppSettings { String? modelId, ThemeMode? themeMode, String? lastAgentId, - String? lastModelId, - String? lastSessionId, - String? lastProjectId, - }) => + String? lastModelId, + String? lastSessionId, + String? lastProjectId, + Map? selectedProfileByAgent, + Map? defaultModelByAgent, + }) => AppSettings( baseUrl: baseUrl ?? this.baseUrl, providerId: providerId ?? this.providerId, modelId: modelId ?? this.modelId, themeMode: themeMode ?? this.themeMode, lastAgentId: lastAgentId ?? this.lastAgentId, - lastModelId: lastModelId ?? this.lastModelId, - lastSessionId: lastSessionId ?? this.lastSessionId, - lastProjectId: lastProjectId ?? this.lastProjectId, - ); + lastModelId: lastModelId ?? this.lastModelId, + lastSessionId: lastSessionId ?? this.lastSessionId, + lastProjectId: lastProjectId ?? this.lastProjectId, + selectedProfileByAgent: + selectedProfileByAgent ?? this.selectedProfileByAgent, + defaultModelByAgent: defaultModelByAgent ?? this.defaultModelByAgent, + ); static const empty = AppSettings( baseUrl: 'http://127.0.0.1:4096', @@ -79,11 +91,13 @@ class SettingsController extends StateNotifier { ? ThemeMode.values[themeModeIndex] : ThemeMode.system, lastAgentId: p.getString(_kLastAgent) ?? '', - lastModelId: p.getString(_kLastModel) ?? '', - lastSessionId: p.getString(_kLastSession) ?? '', - lastProjectId: p.getString(_kLastProject) ?? '', - ); - } + lastModelId: p.getString(_kLastModel) ?? '', + lastSessionId: p.getString(_kLastSession) ?? '', + lastProjectId: p.getString(_kLastProject) ?? '', + selectedProfileByAgent: _readStringMap(p.getString(_kProfilesByAgent)), + defaultModelByAgent: _readStringMap(p.getString(_kModelsByAgent)), + ); + } Future update(AppSettings next) async { state = next; @@ -93,11 +107,16 @@ class SettingsController extends StateNotifier { _prefs.setString(_kModel, next.modelId), _prefs.setInt(_kThemeMode, next.themeMode.index), _prefs.setString(_kLastAgent, next.lastAgentId), - _prefs.setString(_kLastModel, next.lastModelId), - _prefs.setString(_kLastSession, next.lastSessionId), - _prefs.setString(_kLastProject, next.lastProjectId), - ]); - } + _prefs.setString(_kLastModel, next.lastModelId), + _prefs.setString(_kLastSession, next.lastSessionId), + _prefs.setString(_kLastProject, next.lastProjectId), + _prefs.setString( + _kProfilesByAgent, + jsonEncode(next.selectedProfileByAgent), + ), + _prefs.setString(_kModelsByAgent, jsonEncode(next.defaultModelByAgent)), + ]); + } /// Update last-used preferences. Only saves the keys that changed. Future setLastUsed({ @@ -121,8 +140,41 @@ class SettingsController extends StateNotifier { if (projectId != null) { futures.add(_prefs.setString(_kLastProject, projectId)); } - await Future.wait(futures); - } + await Future.wait(futures); + } + + Future setSelectedProfileForAgent(String agentId, String profileId) { + final next = Map.from(state.selectedProfileByAgent); + if (profileId.isEmpty) { + next.remove(agentId); + } else { + next[agentId] = profileId; + } + return update(state.copyWith(selectedProfileByAgent: next)); + } + + Future setDefaultModelForAgent(String agentId, String modelId) { + final next = Map.from(state.defaultModelByAgent); + if (modelId.isEmpty) { + next.remove(agentId); + } else { + next[agentId] = modelId; + } + return update(state.copyWith(defaultModelByAgent: next)); + } + + static Map _readStringMap(String? raw) { + if (raw == null || raw.isEmpty) return const {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return const {}; + return decoded.map( + (key, value) => MapEntry(key.toString(), value.toString()), + ); + } catch (_) { + return const {}; + } + } static const _kBaseUrl = 'oc.baseUrl'; static const _kLegacyToken = 'oc.bearerToken'; @@ -130,10 +182,12 @@ class SettingsController extends StateNotifier { static const _kModel = 'oc.modelId'; static const _kThemeMode = 'oc.themeMode'; static const _kLastAgent = 'oc.lastAgentId'; - static const _kLastModel = 'oc.lastModelId'; - static const _kLastSession = 'oc.lastSessionId'; - static const _kLastProject = 'oc.lastProjectId'; -} + static const _kLastModel = 'oc.lastModelId'; + static const _kLastSession = 'oc.lastSessionId'; + static const _kLastProject = 'oc.lastProjectId'; + static const _kProfilesByAgent = 'oc.selectedProfileByAgent'; + static const _kModelsByAgent = 'oc.defaultModelByAgent'; +} /// Top-level provider. The async dependency is solved with [FutureProvider], /// then we hang the controller off a synchronous provider for ergonomics. diff --git a/lib/ui/pages/agent_group_page.dart b/lib/ui/pages/agent_group_page.dart index e106429..db708c7 100644 --- a/lib/ui/pages/agent_group_page.dart +++ b/lib/ui/pages/agent_group_page.dart @@ -45,18 +45,18 @@ class _AgentGroupPageState extends ConsumerState { orElse: () => null, ); if (match != null) { - _selectedAgent = match; - _selectedPermission = _defaultPermission(match.id); - _modelsFuture = _loadModels(match.id); - } - } - if (_selectedAgent != null && - _selectedModel == null && - settings.lastModelId.isNotEmpty) { - // Model will be resolved once _modelsFuture completes; store the - // preferred ID so we can pick it from the list. - _preferredModelId = settings.lastModelId; - } + _selectedAgent = match; + _selectedPermission = _defaultPermission(match.id); + _modelsFuture = _loadModels(match.id); + } + } + if (_selectedAgent != null && _selectedModel == null) { + // Default model is an explicit per-agent choice. Last-used is only a + // fallback for quick restore when no default exists. + _preferredModelId = + settings.defaultModelByAgent[_selectedAgent!.id] ?? + (settings.lastModelId.isNotEmpty ? settings.lastModelId : null); + } } return Scaffold( @@ -95,11 +95,14 @@ class _AgentGroupPageState extends ConsumerState { selected: _selectedAgent?.id == agent.id, onTap: () => setState(() { _selectedAgent = agent; - _selectedModel = null; - _modelLookupComplete = false; - _modelsFuture = _loadModels(agent.id); - _selectedPermission = _defaultPermission(agent.id); - }), + _selectedModel = null; + _modelLookupComplete = false; + _preferredModelId = + ref.read(settingsControllerProvider).defaultModelByAgent[ + agent.id]; + _modelsFuture = _loadModels(agent.id); + _selectedPermission = _defaultPermission(agent.id); + }), ), if (_selectedAgent != null) ...[ const SizedBox(height: 20), @@ -184,11 +187,13 @@ class _AgentGroupPageState extends ConsumerState { ); } - Future> _loadModels(String agentId) async { - final notifier = ref.read(agentCatalogProvider.notifier); - try { - final models = await notifier.modelsFor(agentId); - return models + Future> _loadModels(String agentId) async { + final notifier = ref.read(agentCatalogProvider.notifier); + final profileId = + ref.read(settingsControllerProvider).selectedProfileByAgent[agentId]; + try { + final models = await notifier.modelsFor(agentId, profileId: profileId); + return models .map( (model) => GatewayModelView( id: model.id, @@ -222,10 +227,12 @@ class _AgentGroupPageState extends ConsumerState { final isCodex = agent.id == 'codex'; final created = await notifier.createSession( agentId: agent.id, - modelId: _selectedModel?.id, - sandbox: isCodex ? _selectedPermission : null, - permissionMode: !isCodex ? _selectedPermission : null, - ); + modelId: _selectedModel?.id, + sandbox: isCodex ? _selectedPermission : null, + permissionMode: !isCodex ? _selectedPermission : null, + profileId: + ref.read(settingsControllerProvider).selectedProfileByAgent[agent.id], + ); if (!mounted) return; final session = readSession(created); // Persist last-used agent/model for next time. diff --git a/lib/ui/pages/settings_page.dart b/lib/ui/pages/settings_page.dart index adce368..8e22d85 100644 --- a/lib/ui/pages/settings_page.dart +++ b/lib/ui/pages/settings_page.dart @@ -1,44 +1,46 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../api/gateway_client.dart'; -import '../../models/agent.dart'; -import '../../state/settings_store.dart'; -import '../widgets/model_picker.dart'; -import 'home_page.dart'; - -enum _AddProfileChoice { official, ccSwitch, manual } - +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../api/gateway_client.dart'; +import '../../models/agent.dart'; +import '../../state/agent_config_filter.dart'; +import '../../state/settings_store.dart'; +import '../widgets/model_picker.dart'; +import 'home_page.dart'; + class _ProviderBadge extends StatelessWidget { - const _ProviderBadge({required this.provider}); - final String? provider; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final (label, color) = switch (provider) { - 'anthropic' => ('Anthropic', const Color(0xFFCC785C)), - 'openai' => ('OpenAI', const Color(0xFF10A37F)), - 'google' => ('Google', const Color(0xFF4285F4)), - 'opencode' => ('OpenCode', const Color(0xFF7C3AED)), - _ => (provider == null || provider!.isEmpty ? 'Other' : provider!, scheme.outline), - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - border: Border.all(color: color.withValues(alpha: 0.5)), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - ), - ), - ); + const _ProviderBadge({required this.provider}); + final String? provider; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final (label, color) = switch (provider) { + 'anthropic' => ('Anthropic', const Color(0xFFCC785C)), + 'openai' => ('OpenAI', const Color(0xFF10A37F)), + 'google' => ('Google', const Color(0xFF4285F4)), + 'opencode' => ('OpenCode', const Color(0xFF7C3AED)), + _ => ( + provider == null || provider!.isEmpty ? 'Other' : provider!, + scheme.outline + ), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + border: Border.all(color: color.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + letterSpacing: 0, + ), + ), + ); } } @@ -91,10 +93,10 @@ class _CredentialEntryTile extends StatelessWidget { Text( entry['tokenPreview'].toString(), style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFeatures: const [ - FontFeature.tabularFigures(), - ], - ), + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), ), if (entry['baseUrl'] != null) Text( @@ -111,289 +113,133 @@ class _CredentialEntryTile extends StatelessWidget { } class SettingsPage extends ConsumerStatefulWidget { - const SettingsPage({super.key, this.firstRun = false}); - final bool firstRun; - - @override - ConsumerState createState() => _SettingsPageState(); -} - + const SettingsPage({super.key, this.firstRun = false}); + final bool firstRun; + + @override + ConsumerState createState() => _SettingsPageState(); +} + class _SettingsPageState extends ConsumerState { late final TextEditingController _baseUrlCtrl; - String _providerId = ''; - String _modelId = ''; - List _models = const []; - Map> _agentModels = const {}; - List _agents = const []; - bool _testing = false; - String? _testError; - bool? _testOk; - - // ── Profiles state ── - List> _profiles = const []; - Map? _activeProfile; - bool _profilesLoading = false; - - @override - void initState() { + Map> _agentModels = const {}; + List _agents = const []; + Set _refreshingAgentIds = const {}; + bool _testing = false; + String? _testError; + bool? _testOk; + + List> _configEntries = const []; + bool _profilesLoading = false; + + @override + void initState() { super.initState(); final s = ref.read(settingsControllerProvider); _baseUrlCtrl = TextEditingController(text: s.baseUrl); - _providerId = s.providerId; - _modelId = s.modelId; - // Auto-test if URL is already configured. - if (s.baseUrl.isNotEmpty) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _testAndLoadModels(); - _loadProfiles(); - }); - } - } - - @override + // Auto-test if URL is already configured. + if (s.baseUrl.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _testAndLoadModels(); + }); + } + } + + @override void dispose() { _baseUrlCtrl.dispose(); super.dispose(); } - - Future _loadProfiles() async { - final url = _baseUrlCtrl.text.trim(); - if (url.isEmpty) return; - setState(() => _profilesLoading = true); - try { - final client = GatewayClient( - baseUrl: Uri.parse(url), - ); - final profiles = await client.listProfiles(); - final active = await client.getActiveProfile(); - client.close(); - if (!mounted) return; - setState(() { - _profiles = profiles; - _activeProfile = active; - }); - } catch (_) { - // Silently ignore — profiles are optional. - } finally { - if (mounted) setState(() => _profilesLoading = false); - } - } - - Future _activateProfile(String profileId) async { - final url = _baseUrlCtrl.text.trim(); - if (url.isEmpty) return; - try { - final client = GatewayClient( - baseUrl: Uri.parse(url), - ); - await client.activateProfile(profileId); - client.close(); - } catch (_) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to activate profile')), - ); - return; - } - await _loadProfiles(); - } - - Future _deleteProfile(String profileId) async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Delete profile?'), - content: const Text('This action cannot be undone.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Delete'), - ), - ], - ), - ); - if (confirmed != true) return; - final url = _baseUrlCtrl.text.trim(); - try { + + Future _loadProfiles() async { + final url = _baseUrlCtrl.text.trim(); + if (url.isEmpty) return; + setState(() => _profilesLoading = true); + try { final client = GatewayClient( baseUrl: Uri.parse(url), ); - await client.deleteProfile(profileId); - client.close(); - } catch (_) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to delete profile')), - ); - return; - } - await _loadProfiles(); - } - - Future _openProfileEditor({Map? existing}) async { - final result = await Navigator.of(context).push( - MaterialPageRoute( + final profiles = await client.listProfiles(); + final configs = >[ + for (final profile in profiles) _normalizeProfileEntry(profile), + ]; + try { + configs.addAll( + (await client.listOfficialCredentials()).map( + (entry) => _normalizeCredentialSourceEntry(entry, 'official'), + ), + ); + } catch (_) { + // Optional source; keep profiles usable if local discovery is absent. + } + try { + configs.addAll( + (await client.listCcSwitchCredentials()).map( + (entry) => _normalizeCredentialSourceEntry(entry, 'cc-switch'), + ), + ); + } catch (_) { + // Optional source; CC-Switch may not be installed on this machine. + } + client.close(); + if (!mounted) return; + setState(() { + _configEntries = configs; + }); + } catch (_) { + } finally { + if (mounted) setState(() => _profilesLoading = false); + } + } + + Map _normalizeProfileEntry(Map profile) { + final providers = credentialEntryProviders(profile); + final provider = providers.isEmpty ? null : providers.first; + final baseUrl = _profileBaseUrl(profile, provider); + return { + ...profile, + 'source': 'profile', + 'label': profile['name'] ?? profile['label'] ?? 'Unnamed', + if (provider != null) 'provider': provider, + if (baseUrl != null && baseUrl.isNotEmpty) 'baseUrl': baseUrl, + }; + } + + Map _normalizeCredentialSourceEntry( + Map entry, + String source, + ) { + return { + ...entry, + 'source': entry['source'] ?? source, + 'label': entry['label'] ?? 'Unnamed', + }; + } + + String? _profileBaseUrl(Map profile, String? provider) { + if (provider == null) return null; + final keys = profile['keys']; + final entry = keys is Map ? keys[provider] : null; + if (entry is Map && entry['baseUrl'] != null) { + return entry['baseUrl'].toString(); + } + return null; + } + + Future _openProfileEditor({Map? existing}) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( builder: (_) => _ProfileEditorPage( baseUrl: _baseUrlCtrl.text.trim(), existing: existing, ), - ), - ); - if (result == true) { - await _loadProfiles(); - } - } - - Future _openAddProfileSheet() async { - final url = _baseUrlCtrl.text.trim(); - if (url.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Set the server URL first')), - ); - return; - } - final choice = await showModalBottomSheet<_AddProfileChoice>( - context: context, - showDragHandle: true, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Add credential profile', - style: Theme.of(ctx).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ListTile( - leading: const Icon(Icons.folder_open_outlined), - title: const Text('From local config files'), - subtitle: const Text( - 'Claude ~/.claude/settings.json, Codex ~/.codex/auth.json', - ), - onTap: () => Navigator.pop(ctx, _AddProfileChoice.official), - ), - ListTile( - leading: const Icon(Icons.swap_horiz_outlined), - title: const Text('From CC-Switch'), - subtitle: const Text( - 'Pick any provider configured in CC-Switch', - ), - onTap: () => Navigator.pop(ctx, _AddProfileChoice.ccSwitch), - ), - ListTile( - leading: const Icon(Icons.edit_outlined), - title: const Text('Enter manually'), - subtitle: const Text( - 'Paste API keys for one or more providers', - ), - onTap: () => Navigator.pop(ctx, _AddProfileChoice.manual), - ), - const SizedBox(height: 8), - ], - ), - ), - ); - if (!mounted || choice == null) return; - switch (choice) { - case _AddProfileChoice.official: - await _importFromSource( - source: 'official', - dialogTitle: 'Pick a local config file', - emptyMessage: - 'No credentials found in ~/.claude/settings.json or ~/.codex/auth.json', - fetch: (c) => c.listOfficialCredentials(), - ); - break; - case _AddProfileChoice.ccSwitch: - await _importFromSource( - source: 'cc-switch', - dialogTitle: 'Pick a CC-Switch provider', - emptyMessage: - 'No providers found in CC-Switch (or node:sqlite unavailable)', - fetch: (c) => c.listCcSwitchCredentials(), - ); - break; - case _AddProfileChoice.manual: - await _openProfileEditor(); - break; - } - } - - Future _importFromSource({ - required String source, - required String dialogTitle, - required String emptyMessage, - required Future>> Function(GatewayClient) fetch, - }) async { - final url = _baseUrlCtrl.text.trim(); - if (url.isEmpty) return; - final client = GatewayClient( - baseUrl: Uri.parse(url), + ), ); - List> entries; - try { - entries = await fetch(client); - } catch (err) { - client.close(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to read source: $err')), - ); - return; - } - if (entries.isEmpty) { - client.close(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(emptyMessage)), - ); - return; - } - final picked = await _pickCredentialEntry(entries, title: dialogTitle); - if (picked == null) { - client.close(); - return; - } - final providerLabel = _providerDisplay(picked['provider']?.toString()); - final defaultName = '${picked['label'] ?? providerLabel} ($providerLabel)'; - final name = await _promptProfileName( - defaultName: defaultName, - entry: picked, - ); - if (name == null) { - client.close(); - return; - } - try { - await client.importProfile( - name: name, - source: source, - sourceId: picked['id']?.toString(), - makeActive: _profiles.isEmpty, - ); - } catch (err) { - client.close(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Import failed: $err')), - ); - return; - } - client.close(); - await _loadProfiles(); - } - + if (result == true) { + await _loadProfiles(); + } + } + Future?> _pickCredentialEntry( List> entries, { required String title, @@ -505,782 +351,838 @@ class _SettingsPageState extends ConsumerState { _ => key.isEmpty ? 'Other' : key, }; } - - Future _promptProfileName({ - required String defaultName, - required Map entry, - }) async { - final ctrl = TextEditingController(text: defaultName); - final result = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Name this profile'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Theme.of(ctx).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - entry['label']?.toString() ?? '', - style: Theme.of(ctx) - .textTheme - .bodyMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - ), - const SizedBox(width: 8), - _ProviderBadge( - provider: entry['provider']?.toString(), - ), - ], - ), - if (entry['tokenPreview'] != null) ...[ - const SizedBox(height: 2), - Text( - entry['tokenPreview'].toString(), - style: Theme.of(ctx).textTheme.bodySmall?.copyWith( - fontFeatures: const [ - FontFeature.tabularFigures(), - ], - ), - ), - ], - if (entry['baseUrl'] != null) ...[ - const SizedBox(height: 2), - Text( - entry['baseUrl'].toString(), - style: Theme.of(ctx).textTheme.bodySmall, - ), - ], - ], - ), - ), - const SizedBox(height: 16), - TextField( - controller: ctrl, - autofocus: true, - decoration: const InputDecoration(labelText: 'Profile name'), - onSubmitted: (v) => Navigator.pop(ctx, v.trim()), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, ctrl.text.trim()), - child: const Text('Import'), - ), - ], - ), - ); - ctrl.dispose(); - return (result == null || result.isEmpty) ? null : result; - } - - String _providerDisplay(String? provider) { - switch (provider) { - case 'anthropic': - return 'Anthropic'; - case 'openai': - return 'OpenAI'; - case 'google': - return 'Google'; - case 'opencode': - return 'OpenCode'; - default: - return provider == null || provider.isEmpty ? 'Provider' : provider; - } - } - - Future _testAndLoadModels() async { - final url = _baseUrlCtrl.text.trim(); - if (url.isEmpty) return; - setState(() { - _testing = true; - _testError = null; - _testOk = null; - }); - try { + + Future _promptProfileName({ + required String defaultName, + required Map entry, + }) async { + final ctrl = TextEditingController(text: defaultName); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Name this profile'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + entry['label']?.toString() ?? '', + style: Theme.of(ctx) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + _ProviderBadge( + provider: entry['provider']?.toString(), + ), + ], + ), + if (entry['tokenPreview'] != null) ...[ + const SizedBox(height: 2), + Text( + entry['tokenPreview'].toString(), + style: Theme.of(ctx).textTheme.bodySmall?.copyWith( + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + if (entry['baseUrl'] != null) ...[ + const SizedBox(height: 2), + Text( + entry['baseUrl'].toString(), + style: Theme.of(ctx).textTheme.bodySmall, + ), + ], + ], + ), + ), + const SizedBox(height: 16), + TextField( + controller: ctrl, + autofocus: true, + decoration: const InputDecoration(labelText: 'Profile name'), + onSubmitted: (v) => Navigator.pop(ctx, v.trim()), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, ctrl.text.trim()), + child: const Text('Import'), + ), + ], + ), + ); + ctrl.dispose(); + return (result == null || result.isEmpty) ? null : result; + } + + String _providerDisplay(String? provider) { + switch (provider) { + case 'anthropic': + return 'Anthropic'; + case 'openai': + return 'OpenAI'; + case 'google': + return 'Google'; + case 'opencode': + return 'OpenCode'; + default: + return provider == null || provider.isEmpty ? 'Provider' : provider; + } + } + + List> _configEntriesForAgent(String agentId) { + return credentialEntriesForAgent(agentId, _configEntries); + } + + Map? _selectedConfigForAgent( + String agentId, + AppSettings settings, + ) { + final profileId = settings.selectedProfileByAgent[agentId]; + if (profileId == null || profileId.isEmpty) return null; + for (final entry in _configEntries) { + if (entry['source'] == 'profile' && + entry['id']?.toString() == profileId) { + return entry; + } + } + return null; + } + + Future _profileIdForConfig(Map entry) async { + final source = entry['source']?.toString() ?? 'profile'; + final id = entry['id']?.toString(); + if (source == 'profile') return id; + if (source != 'official' && source != 'cc-switch') return null; + + final providerLabel = _providerDisplay(entry['provider']?.toString()); + final defaultName = '${entry['label'] ?? providerLabel} ($providerLabel)'; + final name = await _promptProfileName( + defaultName: defaultName, + entry: entry, + ); + if (name == null) return null; + + final url = _baseUrlCtrl.text.trim(); + if (url.isEmpty) return null; + final client = GatewayClient(baseUrl: Uri.parse(url)); + try { + final profile = await client.importProfile( + name: name, + source: source, + sourceId: id, + ); + return profile['id']?.toString(); + } catch (err) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import failed: $err')), + ); + } + return null; + } finally { + client.close(); + } + } + + Future _chooseAgentConfig(Agent agent) async { + if (_configEntries.isEmpty && !_profilesLoading) { + await _loadProfiles(); + } + final entries = _configEntriesForAgent(agent.id); + if (entries.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No ${agent.displayName} config found')), + ); + return; + } + + final picked = await _pickCredentialEntry( + entries, + title: 'Choose ${agent.displayName} config', + ); + if (picked == null) return; + + final profileId = await _profileIdForConfig(picked); + if (profileId == null || profileId.isEmpty) return; + final saved = await _saveAgentSettings( + agent.id, + profileId: profileId, + defaultModel: '', + ); + if (!saved) return; + final controller = ref.read(settingsControllerProvider.notifier); + await controller.setSelectedProfileForAgent(agent.id, profileId); + await controller.setDefaultModelForAgent(agent.id, ''); + await _loadProfiles(); + await _refreshAgentModels(agent, profileId: profileId); + } + + Future _pickDefaultModel(Agent agent) async { + var models = _agentModels[agent.id] ?? const []; + if (models.isEmpty) { + await _refreshAgentModels(agent); + models = _agentModels[agent.id] ?? const []; + } + if (models.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No models returned for ${agent.displayName}')), + ); + return; + } + + final settings = ref.read(settingsControllerProvider); + final selectedId = settings.defaultModelByAgent[agent.id]; + ModelChoice? selected; + for (final model in models) { + if (model.modelId == selectedId) { + selected = model; + break; + } + } + if (!mounted) return; + final picked = await showModelPicker( + context, + models: models, + selected: selected, + ); + if (picked == null) return; + + final saved = await _saveAgentSettings( + agent.id, + defaultModel: picked.modelId, + ); + if (!saved) return; + await ref + .read(settingsControllerProvider.notifier) + .setDefaultModelForAgent(agent.id, picked.modelId); + } + + Future _saveAgentSettings( + String agentId, { + String? profileId, + String? defaultModel, + }) async { + final url = _baseUrlCtrl.text.trim(); + if (url.isEmpty) return false; + final client = GatewayClient(baseUrl: Uri.parse(url)); + try { + await client.updateAgentSettings( + agentId, + profileId: profileId, + defaultModel: defaultModel, + ); + return true; + } catch (err) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save agent settings: $err')), + ); + } + return false; + } finally { + client.close(); + } + } + + Future _refreshAgentModels(Agent agent, {String? profileId}) async { + final url = _baseUrlCtrl.text.trim(); + if (url.isEmpty) return; + setState(() { + _refreshingAgentIds = {..._refreshingAgentIds, agent.id}; + }); + final client = GatewayClient(baseUrl: Uri.parse(url)); + try { + final settings = ref.read(settingsControllerProvider); + final models = await client.listAgentModels( + agent.id, + profileId: profileId ?? settings.selectedProfileByAgent[agent.id], + ); + if (!mounted) return; + setState(() { + final next = Map>.from(_agentModels); + next[agent.id] = _modelChoicesForAgent(agent, models); + _agentModels = next; + }); + } catch (err) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load models: $err')), + ); + } + } finally { + client.close(); + if (mounted) { + setState(() { + _refreshingAgentIds = { + for (final id in _refreshingAgentIds) + if (id != agent.id) id, + }; + }); + } + } + } + + List _modelChoicesForAgent( + Agent agent, + List models, + ) { + return models.map((model) { + final slash = model.id.indexOf('/'); + final provider = slash > 0 ? model.id.substring(0, slash) : agent.id; + return ( + providerId: provider, + modelId: model.id, + label: model.displayName.trim().isEmpty ? model.id : model.displayName, + ); + }).toList(growable: false); + } + + Future _syncRemoteAgentSettings(GatewayClient client) async { + try { + final remote = await client.listAgentSettings(); + final current = ref.read(settingsControllerProvider); + final profiles = Map.from(current.selectedProfileByAgent); + final models = Map.from(current.defaultModelByAgent); + var changed = false; + for (final setting in remote) { + final agentId = setting['agentId']?.toString() ?? ''; + if (agentId.isEmpty) continue; + final profileId = setting['profileId']?.toString() ?? ''; + if (profileId.isNotEmpty && profiles[agentId] != profileId) { + profiles[agentId] = profileId; + changed = true; + } + final defaultModel = setting['defaultModel']?.toString() ?? ''; + if (defaultModel.isNotEmpty && models[agentId] != defaultModel) { + models[agentId] = defaultModel; + changed = true; + } + } + if (changed) { + await ref.read(settingsControllerProvider.notifier).update( + current.copyWith( + selectedProfileByAgent: profiles, + defaultModelByAgent: models, + ), + ); + } + } catch (_) { + // Older gateways may not have agent-scoped settings yet. + } + return ref.read(settingsControllerProvider); + } + + Future _testAndLoadModels() async { + final url = _baseUrlCtrl.text.trim(); + if (url.isEmpty) return; + setState(() { + _testing = true; + _testError = null; + _testOk = null; + }); + try { final client = GatewayClient( baseUrl: Uri.parse(url), ); - final ok = await client.health(); - if (!ok) { - setState(() { - _testOk = false; - _testError = 'Server unreachable.'; - }); - client.close(); - return; - } - final agents = await client.listAgents(); - final models = []; - final perAgent = >{}; - for (final agent in agents) { - if (!agent.supportsModels) continue; - final agentModelList = await client.listAgentModels(agent.id); - final choices = agentModelList - .map( - (model) { - final slash = model.id.indexOf('/'); - final provider = slash > 0 ? model.id.substring(0, slash) : agent.id; - return ( - providerId: provider, - modelId: model.id, - label: model.displayName.trim().isEmpty - ? model.id - : model.displayName, - ); - }, - ) - .toList(); - models.addAll(choices); - perAgent[agent.id] = choices; - } - client.close(); - if (!mounted) return; - setState(() { - _agents = agents; - _models = models; - _agentModels = perAgent; - _testOk = true; - final exists = models.any( - (m) => m.providerId == _providerId && m.modelId == _modelId, - ); - if (!exists && models.isNotEmpty) { - _providerId = models.first.providerId; - _modelId = models.first.modelId; - } - }); - } catch (err) { - setState(() { - _testOk = false; - _testError = '$err'; - }); - } finally { - if (mounted) setState(() => _testing = false); - } - } - - Future _save() async { - final controller = ref.read(settingsControllerProvider.notifier); - final current = ref.read(settingsControllerProvider); - await controller.update( - AppSettings( - baseUrl: _baseUrlCtrl.text.trim(), - providerId: _providerId, - modelId: _modelId, - themeMode: current.themeMode, - ), - ); - if (!mounted) return; - if (widget.firstRun) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const HomePage()), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Settings saved')), - ); - } - } - - Future _openModelPicker() async { - final selected = _models.isEmpty - ? null - : _models.firstWhere( - (m) => m.providerId == _providerId && m.modelId == _modelId, - orElse: () => _models.first, - ); - final picked = await showModelPicker( - context, - models: _models, - selected: selected, - ); - if (picked == null) return; - setState(() { - _providerId = picked.providerId; - _modelId = picked.modelId; - }); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final canSave = _baseUrlCtrl.text.trim().isNotEmpty && - _providerId.isNotEmpty && - _modelId.isNotEmpty; - - return Scaffold( - appBar: AppBar( - title: const Text('Settings'), - automaticallyImplyLeading: widget.firstRun, - actions: [ - if (!widget.firstRun) - TextButton( - onPressed: canSave ? _save : null, - child: const Text('Save'), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), - children: [ - // ── Profiles section ─────────────────────────────────────────── - const _SectionHeader(title: 'Profiles', icon: Icons.person_outlined), - const SizedBox(height: 10), - if (_activeProfile != null) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - 'Active: ${_activeProfile!['name'] ?? 'Unnamed'}', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - if (_profilesLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center(child: CircularProgressIndicator(strokeWidth: 2)), - ) - else ...[ - if (_profiles.isEmpty) - Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.info_outline, - size: 18, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - 'No credentials yet', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - 'The gateway holds all API credentials. Add one to start a session — import from local config files, pick from CC-Switch, or paste keys manually.', - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - for (final profile in _profiles) - _ProfileTile( - profile: profile, - isActive: _activeProfile != null && - _activeProfile!['id'] == profile['id'], - onTap: () { - final id = profile['id'] as String?; - if (id != null) _activateProfile(id); - }, - onEdit: () => _openProfileEditor(existing: profile), - onDelete: () { - final id = profile['id'] as String?; - if (id != null) _deleteProfile(id); - }, - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _openAddProfileSheet, - icon: const Icon(Icons.add, size: 18), - label: const Text('Add Profile'), - ), - ], - const SizedBox(height: 28), - // ── Connection section ────────────────────────────────────────── - const _SectionHeader(title: 'Connection', icon: Icons.dns_outlined), - const SizedBox(height: 10), - TextField( - controller: _baseUrlCtrl, - decoration: const InputDecoration( - labelText: 'Server URL', - hintText: 'http://100.x.x.x:4096', - prefixIcon: Icon(Icons.link), - ), - keyboardType: TextInputType.url, - autocorrect: false, - ), + final ok = await client.health(); + if (!ok) { + setState(() { + _testOk = false; + _testError = 'Server unreachable.'; + }); + client.close(); + return; + } + await _loadProfiles(); + final agents = await client.listAgents(); + final perAgent = >{}; + final settings = await _syncRemoteAgentSettings(client); + for (final agent in agents) { + if (!agent.supportsModels) continue; + final agentModelList = await client.listAgentModels( + agent.id, + profileId: settings.selectedProfileByAgent[agent.id], + ); + perAgent[agent.id] = _modelChoicesForAgent(agent, agentModelList); + } + client.close(); + if (!mounted) return; + setState(() { + _agents = agents; + _agentModels = perAgent; + _testOk = true; + }); + } catch (err) { + setState(() { + _testOk = false; + _testError = '$err'; + }); + } finally { + if (mounted) setState(() => _testing = false); + } + } + + Future _save() async { + final controller = ref.read(settingsControllerProvider.notifier); + final current = ref.read(settingsControllerProvider); + await controller.update( + current.copyWith(baseUrl: _baseUrlCtrl.text.trim()), + ); + if (!mounted) return; + if (widget.firstRun) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomePage()), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings saved')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final settings = ref.watch(settingsControllerProvider); + final canSave = _baseUrlCtrl.text.trim().isNotEmpty; + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + automaticallyImplyLeading: widget.firstRun, + actions: [ + if (!widget.firstRun) + TextButton( + onPressed: canSave ? _save : null, + child: const Text('Save'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + const _SectionHeader(title: 'Connection', icon: Icons.dns_outlined), + const SizedBox(height: 10), + TextField( + controller: _baseUrlCtrl, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'http://100.x.x.x:4096', + prefixIcon: Icon(Icons.link), + ), + keyboardType: TextInputType.url, + autocorrect: false, + ), const SizedBox(height: 14), - Row( - children: [ - FilledButton.icon( - onPressed: _testing ? null : _testAndLoadModels, - icon: _testing - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.bolt_outlined, size: 18), - label: Text(_testing ? 'Connecting...' : 'Test connection'), - ), - const SizedBox(width: 12), - if (_testOk == true) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check_circle, color: Colors.green, size: 16), - SizedBox(width: 4), - Text( - 'Connected', - style: TextStyle( - color: Colors.green, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - if (_testOk == false) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - color: theme.colorScheme.error, - size: 16, - ), - const SizedBox(width: 4), - Text( - 'Failed', - style: TextStyle( - color: theme.colorScheme.error, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - if (_testError != null) ...[ - const SizedBox(height: 8), - Text( - _testError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ], - // ── Agents & Models section ──────────────────────────────────── - if (_agents.isNotEmpty) ...[ - const SizedBox(height: 28), - const _SectionHeader( - title: 'Agents & Models', - icon: Icons.smart_toy_outlined, - ), - const SizedBox(height: 10), - for (final agent in _agents) - _AgentModelSection( - agent: agent, - models: _agentModels[agent.id] ?? const [], - ), - ], - // ── Default model section ────────────────────────────────────── - if (_models.isNotEmpty) ...[ - const SizedBox(height: 28), - const _SectionHeader( - title: 'Default Model', - icon: Icons.psychology_outlined, - ), - const SizedBox(height: 10), - _ModelTile( - providerId: _providerId, - modelId: _modelId, - modelCount: _models.length, - onTap: _openModelPicker, - ), - ], - // ── Appearance section ───────────────────────────────────────── - const SizedBox(height: 28), - const _SectionHeader( - title: 'Appearance', - icon: Icons.palette_outlined, - ), - const SizedBox(height: 10), - _ThemeSelector( - current: ref.watch(settingsControllerProvider).themeMode, - onChanged: (mode) { - final ctrl = ref.read(settingsControllerProvider.notifier); - ctrl.update( - ref.read(settingsControllerProvider).copyWith(themeMode: mode), - ); - }, - ), - const SizedBox(height: 32), - if (widget.firstRun) - FilledButton.tonal( - onPressed: canSave ? _save : null, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text('Continue'), - ), - ), - ], - ), - ); - } -} - -/// Expandable section showing one agent and its available models. -class _AgentModelSection extends StatelessWidget { - const _AgentModelSection({ - required this.agent, - required this.models, - }); - - final Agent agent; - final List models; - - IconData _agentIcon(String id) => switch (id) { - 'codex' => Icons.code, - 'claude-code' => Icons.auto_awesome, - 'opencode' => Icons.terminal, - _ => Icons.smart_toy_outlined, - }; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final available = agent.raw['available'] == true; - return Card( - margin: const EdgeInsets.only(bottom: 8), - clipBehavior: Clip.antiAlias, - child: ExpansionTile( - leading: Icon( - _agentIcon(agent.id), - color: available - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, - ), - title: Text(agent.displayName), - subtitle: Text( - available - ? '${models.length} model${models.length == 1 ? '' : 's'} available' - : 'Not installed', - style: theme.textTheme.bodySmall?.copyWith( - color: available ? null : theme.colorScheme.error, - ), - ), - trailing: available - ? null - : Icon(Icons.warning_amber, color: theme.colorScheme.error), - children: [ - if (models.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Text( - 'No models loaded for this agent.', - style: theme.textTheme.bodySmall, - ), - ) - else - ...models.map( - (m) => ListTile( - dense: true, - visualDensity: VisualDensity.compact, - leading: - const Icon(Icons.psychology_outlined, size: 18), - title: Text( - m.modelId, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - ), - ), - ], - ), - ); - } -} - -/// Card-style tile that shows the chosen `provider / model` and opens the picker. -class _ModelTile extends StatelessWidget { - const _ModelTile({ - required this.providerId, - required this.modelId, - required this.modelCount, - required this.onTap, - }); - - final String providerId; - final String modelId; - final int modelCount; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isEmpty = providerId.isEmpty || modelId.isEmpty; - return Material( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - const Icon(Icons.psychology_outlined), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isEmpty ? 'Select a model' : modelId, - style: theme.textTheme.titleSmall?.copyWith( - fontFamily: 'monospace', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - isEmpty - ? '$modelCount models available / tap to choose' - : 'Provider: $providerId / $modelCount available', - style: theme.textTheme.labelSmall, - ), - ], - ), - ), - const Icon(Icons.unfold_more), - ], - ), - ), - ), - ); - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader({required this.title, required this.icon}); - final String title; - final IconData icon; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Row( - children: [ - Icon(icon, size: 18, color: scheme.primary), - const SizedBox(width: 8), - Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: scheme.primary, - fontWeight: FontWeight.w700, - ), - ), - ], - ); - } -} - -class _ThemeSelector extends StatelessWidget { - const _ThemeSelector({ - required this.current, - required this.onChanged, - }); - - final ThemeMode current; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return SegmentedButton( - segments: const [ - ButtonSegment( - value: ThemeMode.system, - label: Text('System'), - icon: Icon(Icons.brightness_auto), - ), - ButtonSegment( - value: ThemeMode.light, - label: Text('Light'), - icon: Icon(Icons.light_mode), - ), - ButtonSegment( - value: ThemeMode.dark, - label: Text('Dark'), - icon: Icon(Icons.dark_mode), - ), - ], - selected: {current}, - onSelectionChanged: (s) => onChanged(s.first), - ); - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Profile Tile -// ═══════════════════════════════════════════════════════════════════════════════ - -class _ProfileTile extends StatelessWidget { - const _ProfileTile({ - required this.profile, - required this.isActive, - required this.onTap, - required this.onEdit, - required this.onDelete, - }); - - final Map profile; - final bool isActive; - final VoidCallback onTap; - final VoidCallback onEdit; - final VoidCallback onDelete; - - String _keysSummary() { - final keys = profile['keys']; - if (keys is! Map || keys.isEmpty) return 'No keys'; - final parts = []; - for (final entry in keys.entries) { - final provider = entry.key; - final value = entry.value; - final hasKey = value is Map && - (value['key'] as String? ?? '').isNotEmpty; - if (hasKey) parts.add('$provider ✓'); - } - return parts.isEmpty ? 'No keys' : parts.join(', '); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final name = profile['name'] as String? ?? 'Unnamed'; - return Card( - margin: const EdgeInsets.only(bottom: 6), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - onLongPress: onEdit, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: isActive ? Colors.green : Colors.transparent, - border: Border.all( - color: isActive - ? Colors.green - : theme.colorScheme.outline, - ), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: - isActive ? FontWeight.w700 : FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - _keysSummary(), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - PopupMenuButton( - icon: Icon( - Icons.more_vert, - size: 20, - color: theme.colorScheme.onSurfaceVariant, - ), - onSelected: (value) { - if (value == 'edit') onEdit(); - if (value == 'delete') onDelete(); - }, - itemBuilder: (_) => const [ - PopupMenuItem(value: 'edit', child: Text('Edit')), - PopupMenuItem(value: 'delete', child: Text('Delete')), - ], - ), - ], - ), - ), - ), - ); - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Profile Editor Page -// ═══════════════════════════════════════════════════════════════════════════════ - + Row( + children: [ + FilledButton.icon( + onPressed: _testing ? null : _testAndLoadModels, + icon: _testing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.bolt_outlined, size: 18), + label: Text(_testing ? 'Connecting...' : 'Test connection'), + ), + const SizedBox(width: 12), + if (_testOk == true) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Colors.green, size: 16), + SizedBox(width: 4), + Text( + 'Connected', + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + if (_testOk == false) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: theme.colorScheme.error, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Failed', + style: TextStyle( + color: theme.colorScheme.error, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + if (_testError != null) ...[ + const SizedBox(height: 8), + Text( + _testError!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + if (_agents.isNotEmpty) ...[ + const SizedBox(height: 28), + const _SectionHeader( + title: 'Agents & Models', + icon: Icons.smart_toy_outlined, + ), + const SizedBox(height: 10), + for (final agent in _agents) + _AgentModelSection( + agent: agent, + models: _agentModels[agent.id] ?? const [], + configEntries: _configEntriesForAgent(agent.id), + selectedConfig: _selectedConfigForAgent(agent.id, settings), + selectedModelId: settings.defaultModelByAgent[agent.id], + loadingConfigs: _profilesLoading, + loadingModels: _refreshingAgentIds.contains(agent.id), + onChooseConfig: () => _chooseAgentConfig(agent), + onPickDefaultModel: () => _pickDefaultModel(agent), + onRefreshModels: () => _refreshAgentModels(agent), + onAddManualConfig: () => _openProfileEditor(), + ), + ], + const SizedBox(height: 28), + const _SectionHeader( + title: 'Appearance', + icon: Icons.palette_outlined, + ), + const SizedBox(height: 10), + _ThemeSelector( + current: settings.themeMode, + onChanged: (mode) { + final ctrl = ref.read(settingsControllerProvider.notifier); + ctrl.update( + ref.read(settingsControllerProvider).copyWith(themeMode: mode), + ); + }, + ), + const SizedBox(height: 32), + if (widget.firstRun) + FilledButton.tonal( + onPressed: canSave ? _save : null, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Continue'), + ), + ), + ], + ), + ); + } +} + +/// Expandable section showing one agent and its available models. +class _AgentModelSection extends StatelessWidget { + const _AgentModelSection({ + required this.agent, + required this.models, + required this.configEntries, + required this.selectedConfig, + required this.selectedModelId, + required this.loadingConfigs, + required this.loadingModels, + required this.onChooseConfig, + required this.onPickDefaultModel, + required this.onRefreshModels, + required this.onAddManualConfig, + }); + + final Agent agent; + final List models; + final List> configEntries; + final Map? selectedConfig; + final String? selectedModelId; + final bool loadingConfigs; + final bool loadingModels; + final VoidCallback onChooseConfig; + final VoidCallback onPickDefaultModel; + final VoidCallback onRefreshModels; + final VoidCallback onAddManualConfig; + + IconData _agentIcon(String id) => switch (id) { + 'codex' => Icons.code, + 'claude-code' => Icons.auto_awesome, + 'opencode' => Icons.terminal, + _ => Icons.smart_toy_outlined, + }; + + String _configTitle() { + final config = selectedConfig; + if (config == null) return 'No config selected'; + return config['label']?.toString() ?? + config['name']?.toString() ?? + 'Unnamed'; + } + + String _configSubtitle() { + final config = selectedConfig; + if (config == null) { + return configEntries.isEmpty + ? 'No matching config found' + : '${configEntries.length} configs available'; + } + final source = switch (config['source']?.toString()) { + 'profile' => 'Gateway profile', + 'official' => 'Local config', + 'cc-switch' => 'CC-Switch', + _ => 'Config', + }; + final provider = config['provider']?.toString(); + final baseUrl = config['baseUrl']?.toString(); + return [ + source, + if (provider != null && provider.isNotEmpty) provider, + if (baseUrl != null && baseUrl.isNotEmpty) baseUrl, + ].join(' / '); + } + + String _agentSubtitle(bool available) { + final modelText = '${models.length} model${models.length == 1 ? '' : 's'}'; + if (!available) return 'Not installed / $modelText loaded'; + if (selectedConfig != null && + selectedModelId != null && + selectedModelId!.isNotEmpty) { + return '$modelText / default: $selectedModelId'; + } + return '$modelText available'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final available = agent.raw['available'] == true; + return Card( + margin: const EdgeInsets.only(bottom: 8), + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + leading: Icon( + _agentIcon(agent.id), + color: available + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + title: Text(agent.displayName), + subtitle: Text( + _agentSubtitle(available), + style: theme.textTheme.bodySmall?.copyWith( + color: available ? null : theme.colorScheme.error, + ), + ), + trailing: available + ? null + : Icon(Icons.warning_amber, color: theme.colorScheme.error), + children: [ + ListTile( + dense: true, + leading: const Icon(Icons.manage_accounts_outlined, size: 20), + title: Text(_configTitle()), + subtitle: Text( + loadingConfigs ? 'Loading configs...' : _configSubtitle(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: Wrap( + spacing: 4, + children: [ + IconButton( + tooltip: 'Add manual config', + icon: const Icon(Icons.add), + onPressed: onAddManualConfig, + ), + IconButton( + tooltip: 'Choose config', + icon: const Icon(Icons.tune), + onPressed: loadingConfigs ? null : onChooseConfig, + ), + ], + ), + ), + ListTile( + dense: true, + leading: const Icon(Icons.psychology_outlined, size: 20), + title: Text( + selectedModelId == null || selectedModelId!.isEmpty + ? 'No default model' + : selectedModelId!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: selectedModelId == null || selectedModelId!.isEmpty + ? null + : 'monospace', + ), + ), + subtitle: Text( + '${models.length} model${models.length == 1 ? '' : 's'} loaded', + ), + trailing: Wrap( + spacing: 4, + children: [ + IconButton( + tooltip: 'Refresh models', + icon: loadingModels + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + onPressed: loadingModels ? null : onRefreshModels, + ), + IconButton( + tooltip: 'Choose default model', + icon: const Icon(Icons.unfold_more), + onPressed: loadingModels ? null : onPickDefaultModel, + ), + ], + ), + ), + if (models.isNotEmpty) + ...models.take(8).map( + (m) => ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: const Icon(Icons.circle, size: 8), + title: Text( + m.modelId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ) + else + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + loadingModels ? 'Loading models...' : 'No models loaded', + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title, required this.icon}); + final String title; + final IconData icon; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Row( + children: [ + Icon(icon, size: 18, color: scheme.primary), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} + +class _ThemeSelector extends StatelessWidget { + const _ThemeSelector({ + required this.current, + required this.onChanged, + }); + + final ThemeMode current; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.system, + label: Text('System'), + icon: Icon(Icons.brightness_auto), + ), + ButtonSegment( + value: ThemeMode.light, + label: Text('Light'), + icon: Icon(Icons.light_mode), + ), + ButtonSegment( + value: ThemeMode.dark, + label: Text('Dark'), + icon: Icon(Icons.dark_mode), + ), + ], + selected: {current}, + onSelectionChanged: (s) => onChanged(s.first), + ); + } +} + class _ProfileEditorPage extends StatefulWidget { const _ProfileEditorPage({ required this.baseUrl, @@ -1289,263 +1191,263 @@ class _ProfileEditorPage extends StatefulWidget { final String baseUrl; final Map? existing; - - @override - State<_ProfileEditorPage> createState() => _ProfileEditorPageState(); -} - -class _ProfileEditorPageState extends State<_ProfileEditorPage> { - late final TextEditingController _nameCtrl; - - // Per-provider key controllers - late final TextEditingController _anthropicKeyCtrl; - late final TextEditingController _anthropicBaseUrlCtrl; - late final TextEditingController _openaiKeyCtrl; - late final TextEditingController _openaiBaseUrlCtrl; - late final TextEditingController _opencodeKeyCtrl; - late final TextEditingController _opencodeBaseUrlCtrl; - - // Masked key hints shown as placeholder when editing - String _anthropicKeyHint = ''; - String _openaiKeyHint = ''; - String _opencodeKeyHint = ''; - - final Map _obscure = { - 'anthropic': true, - 'openai': true, - 'opencode': true, - }; - - bool _saving = false; - - bool get _isEditing => widget.existing != null; - - @override - void initState() { - super.initState(); - final existing = widget.existing; - _nameCtrl = TextEditingController( - text: existing?['name'] as String? ?? '', - ); - - final keys = existing?['keys'] as Map? ?? {}; - final anthropic = keys['anthropic'] as Map? ?? {}; - final openai = keys['openai'] as Map? ?? {}; - final opencode = keys['opencode'] as Map? ?? {}; - - // When editing, don't populate key fields with masked values. - // Show masked values as hints only; empty field means "keep existing". - final isEdit = existing != null; + + @override + State<_ProfileEditorPage> createState() => _ProfileEditorPageState(); +} + +class _ProfileEditorPageState extends State<_ProfileEditorPage> { + late final TextEditingController _nameCtrl; + + // Per-provider key controllers + late final TextEditingController _anthropicKeyCtrl; + late final TextEditingController _anthropicBaseUrlCtrl; + late final TextEditingController _openaiKeyCtrl; + late final TextEditingController _openaiBaseUrlCtrl; + late final TextEditingController _opencodeKeyCtrl; + late final TextEditingController _opencodeBaseUrlCtrl; + + // Masked key hints shown as placeholder when editing + String _anthropicKeyHint = ''; + String _openaiKeyHint = ''; + String _opencodeKeyHint = ''; + + final Map _obscure = { + 'anthropic': true, + 'openai': true, + 'opencode': true, + }; + + bool _saving = false; + + bool get _isEditing => widget.existing != null; + + @override + void initState() { + super.initState(); + final existing = widget.existing; + _nameCtrl = TextEditingController( + text: existing?['name'] as String? ?? '', + ); + + final keys = existing?['keys'] as Map? ?? {}; + final anthropic = keys['anthropic'] as Map? ?? {}; + final openai = keys['openai'] as Map? ?? {}; + final opencode = keys['opencode'] as Map? ?? {}; + + // When editing, don't populate key fields with masked values. + // Show masked values as hints only; empty field means "keep existing". + final isEdit = existing != null; _anthropicKeyCtrl = TextEditingController( text: isEdit ? '' : (anthropic['key'] as String? ?? ''), ); - _anthropicBaseUrlCtrl = - TextEditingController(text: anthropic['baseUrl'] as String? ?? ''); + _anthropicBaseUrlCtrl = + TextEditingController(text: anthropic['baseUrl'] as String? ?? ''); _openaiKeyCtrl = TextEditingController( text: isEdit ? '' : (openai['key'] as String? ?? ''), ); - _openaiBaseUrlCtrl = - TextEditingController(text: openai['baseUrl'] as String? ?? ''); + _openaiBaseUrlCtrl = + TextEditingController(text: openai['baseUrl'] as String? ?? ''); _opencodeKeyCtrl = TextEditingController( text: isEdit ? '' : (opencode['key'] as String? ?? ''), ); - _opencodeBaseUrlCtrl = - TextEditingController(text: opencode['baseUrl'] as String? ?? ''); - - _anthropicKeyHint = anthropic['key'] as String? ?? ''; - _openaiKeyHint = openai['key'] as String? ?? ''; - _opencodeKeyHint = opencode['key'] as String? ?? ''; - } - - @override - void dispose() { - _nameCtrl.dispose(); - _anthropicKeyCtrl.dispose(); - _anthropicBaseUrlCtrl.dispose(); - _openaiKeyCtrl.dispose(); - _openaiBaseUrlCtrl.dispose(); - _opencodeKeyCtrl.dispose(); - _opencodeBaseUrlCtrl.dispose(); - super.dispose(); - } - - Map _buildKeys() { - final keys = {}; - void addProvider( - String name, - TextEditingController keyCtrl, - TextEditingController baseUrlCtrl, - ) { - final key = keyCtrl.text.trim(); - final baseUrl = baseUrlCtrl.text.trim(); - if (key.isNotEmpty || baseUrl.isNotEmpty) { - keys[name] = { - if (key.isNotEmpty) 'key': key, - if (baseUrl.isNotEmpty) 'baseUrl': baseUrl, - }; - } - } - - addProvider('anthropic', _anthropicKeyCtrl, _anthropicBaseUrlCtrl); - addProvider('openai', _openaiKeyCtrl, _openaiBaseUrlCtrl); - addProvider('opencode', _opencodeKeyCtrl, _opencodeBaseUrlCtrl); - return keys; - } - - Future _save() async { - final name = _nameCtrl.text.trim(); - if (name.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Profile name is required')), - ); - return; - } - setState(() => _saving = true); - try { + _opencodeBaseUrlCtrl = + TextEditingController(text: opencode['baseUrl'] as String? ?? ''); + + _anthropicKeyHint = anthropic['key'] as String? ?? ''; + _openaiKeyHint = openai['key'] as String? ?? ''; + _opencodeKeyHint = opencode['key'] as String? ?? ''; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _anthropicKeyCtrl.dispose(); + _anthropicBaseUrlCtrl.dispose(); + _openaiKeyCtrl.dispose(); + _openaiBaseUrlCtrl.dispose(); + _opencodeKeyCtrl.dispose(); + _opencodeBaseUrlCtrl.dispose(); + super.dispose(); + } + + Map _buildKeys() { + final keys = {}; + void addProvider( + String name, + TextEditingController keyCtrl, + TextEditingController baseUrlCtrl, + ) { + final key = keyCtrl.text.trim(); + final baseUrl = baseUrlCtrl.text.trim(); + if (key.isNotEmpty || baseUrl.isNotEmpty) { + keys[name] = { + if (key.isNotEmpty) 'key': key, + if (baseUrl.isNotEmpty) 'baseUrl': baseUrl, + }; + } + } + + addProvider('anthropic', _anthropicKeyCtrl, _anthropicBaseUrlCtrl); + addProvider('openai', _openaiKeyCtrl, _openaiBaseUrlCtrl); + addProvider('opencode', _opencodeKeyCtrl, _opencodeBaseUrlCtrl); + return keys; + } + + Future _save() async { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile name is required')), + ); + return; + } + setState(() => _saving = true); + try { final client = GatewayClient( baseUrl: Uri.parse(widget.baseUrl), ); - final keys = _buildKeys(); - if (_isEditing) { - final id = widget.existing!['id'] as String; - await client.updateProfile(id, name: name, keys: keys); - } else { - await client.createProfile(name: name, keys: keys); - } - client.close(); - if (!mounted) return; - Navigator.of(context).pop(true); - } catch (err) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save profile: $err')), - ); - } finally { - if (mounted) setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - title: Text(_isEditing ? 'Edit Profile' : 'New Profile'), - actions: [ - TextButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Save'), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), - children: [ - TextField( - controller: _nameCtrl, - decoration: const InputDecoration( - labelText: 'Profile Name', - prefixIcon: Icon(Icons.label_outlined), - ), - textCapitalization: TextCapitalization.words, - ), - const SizedBox(height: 24), - _buildProviderSection( - theme: theme, - title: 'Anthropic', - providerKey: 'anthropic', - keyCtrl: _anthropicKeyCtrl, - baseUrlCtrl: _anthropicBaseUrlCtrl, - keyHint: _anthropicKeyHint, - ), - const SizedBox(height: 16), - _buildProviderSection( - theme: theme, - title: 'OpenAI', - providerKey: 'openai', - keyCtrl: _openaiKeyCtrl, - baseUrlCtrl: _openaiBaseUrlCtrl, - keyHint: _openaiKeyHint, - ), - const SizedBox(height: 16), - _buildProviderSection( - theme: theme, - title: 'OpenCode', - providerKey: 'opencode', - keyCtrl: _opencodeKeyCtrl, - baseUrlCtrl: _opencodeBaseUrlCtrl, - keyHint: _opencodeKeyHint, - ), - ], - ), - ); - } - - Widget _buildProviderSection({ - required ThemeData theme, - required String title, - required String providerKey, - required TextEditingController keyCtrl, - required TextEditingController baseUrlCtrl, - String keyHint = '', - }) { - final isObscured = _obscure[providerKey] ?? true; - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - TextField( - controller: keyCtrl, - obscureText: isObscured, - decoration: InputDecoration( - labelText: 'API Key', - hintText: keyHint.isNotEmpty ? keyHint : null, - prefixIcon: const Icon(Icons.vpn_key_outlined), - suffixIcon: IconButton( - icon: Icon( - isObscured ? Icons.visibility_off : Icons.visibility, - size: 20, - ), - onPressed: () { - setState(() { - _obscure[providerKey] = !isObscured; - }); - }, - ), - isDense: true, - ), - autocorrect: false, - ), - const SizedBox(height: 10), - TextField( - controller: baseUrlCtrl, - decoration: const InputDecoration( - labelText: 'Base URL (optional)', - prefixIcon: Icon(Icons.link), - isDense: true, - ), - keyboardType: TextInputType.url, - autocorrect: false, - ), - ], - ), - ), - ); - } -} + final keys = _buildKeys(); + if (_isEditing) { + final id = widget.existing!['id'] as String; + await client.updateProfile(id, name: name, keys: keys); + } else { + await client.createProfile(name: name, keys: keys); + } + client.close(); + if (!mounted) return; + Navigator.of(context).pop(true); + } catch (err) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save profile: $err')), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text(_isEditing ? 'Edit Profile' : 'New Profile'), + actions: [ + TextButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + TextField( + controller: _nameCtrl, + decoration: const InputDecoration( + labelText: 'Profile Name', + prefixIcon: Icon(Icons.label_outlined), + ), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 24), + _buildProviderSection( + theme: theme, + title: 'Anthropic', + providerKey: 'anthropic', + keyCtrl: _anthropicKeyCtrl, + baseUrlCtrl: _anthropicBaseUrlCtrl, + keyHint: _anthropicKeyHint, + ), + const SizedBox(height: 16), + _buildProviderSection( + theme: theme, + title: 'OpenAI', + providerKey: 'openai', + keyCtrl: _openaiKeyCtrl, + baseUrlCtrl: _openaiBaseUrlCtrl, + keyHint: _openaiKeyHint, + ), + const SizedBox(height: 16), + _buildProviderSection( + theme: theme, + title: 'OpenCode', + providerKey: 'opencode', + keyCtrl: _opencodeKeyCtrl, + baseUrlCtrl: _opencodeBaseUrlCtrl, + keyHint: _opencodeKeyHint, + ), + ], + ), + ); + } + + Widget _buildProviderSection({ + required ThemeData theme, + required String title, + required String providerKey, + required TextEditingController keyCtrl, + required TextEditingController baseUrlCtrl, + String keyHint = '', + }) { + final isObscured = _obscure[providerKey] ?? true; + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + TextField( + controller: keyCtrl, + obscureText: isObscured, + decoration: InputDecoration( + labelText: 'API Key', + hintText: keyHint.isNotEmpty ? keyHint : null, + prefixIcon: const Icon(Icons.vpn_key_outlined), + suffixIcon: IconButton( + icon: Icon( + isObscured ? Icons.visibility_off : Icons.visibility, + size: 20, + ), + onPressed: () { + setState(() { + _obscure[providerKey] = !isObscured; + }); + }, + ), + isDense: true, + ), + autocorrect: false, + ), + const SizedBox(height: 10), + TextField( + controller: baseUrlCtrl, + decoration: const InputDecoration( + labelText: 'Base URL (optional)', + prefixIcon: Icon(Icons.link), + isDense: true, + ), + keyboardType: TextInputType.url, + autocorrect: false, + ), + ], + ), + ), + ); + } +} diff --git a/test/state/agent_config_filter_test.dart b/test/state/agent_config_filter_test.dart new file mode 100644 index 0000000..991d179 --- /dev/null +++ b/test/state/agent_config_filter_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:remote_multi_agent/state/agent_config_filter.dart'; + +void main() { + test('filters credential entries by the agent that can use them', () { + final entries = >[ + { + 'id': 'profile-openai', + 'source': 'profile', + 'keys': { + 'openai': {'key': '***'}, + }, + }, + { + 'id': 'profile-anthropic', + 'source': 'profile', + 'keys': { + 'anthropic': {'key': '***'}, + }, + }, + { + 'id': 'cc-opencode', + 'source': 'cc-switch', + 'provider': 'opencode', + 'raw': {'appType': 'opencode'}, + }, + ]; + + expect( + credentialEntriesForAgent('codex', entries).map((e) => e['id']), + ['profile-openai'], + ); + expect( + credentialEntriesForAgent('claude-code', entries).map((e) => e['id']), + ['profile-anthropic'], + ); + expect( + credentialEntriesForAgent('opencode', entries).map((e) => e['id']), + ['profile-openai', 'profile-anthropic', 'cc-opencode'], + ); + }); +} diff --git a/test/state/settings_store_test.dart b/test/state/settings_store_test.dart new file mode 100644 index 0000000..26c5eea --- /dev/null +++ b/test/state/settings_store_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remote_multi_agent/state/settings_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('isConfigured only requires a gateway base URL', () async { + SharedPreferences.setMockInitialValues({ + 'oc.baseUrl': 'http://127.0.0.1:4096', + 'oc.providerId': '', + 'oc.modelId': '', + }); + final prefs = await SharedPreferences.getInstance(); + final controller = SettingsController(prefs); + + expect(controller.state.isConfigured, isTrue); + }); + + test('persists selected profile and default model per agent', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final controller = SettingsController(prefs); + + await controller.update( + controller.state.copyWith( + themeMode: ThemeMode.dark, + selectedProfileByAgent: const { + 'codex': 'profile-openai', + 'claude-code': 'profile-anthropic', + }, + defaultModelByAgent: const { + 'codex': 'openai/gpt-5', + 'claude-code': 'anthropic/claude-sonnet-4-5', + }, + ), + ); + + final reloaded = SettingsController(prefs).state; + + expect(reloaded.selectedProfileByAgent['codex'], 'profile-openai'); + expect(reloaded.selectedProfileByAgent['claude-code'], 'profile-anthropic'); + expect(reloaded.defaultModelByAgent['codex'], 'openai/gpt-5'); + expect( + reloaded.defaultModelByAgent['claude-code'], + 'anthropic/claude-sonnet-4-5', + ); + expect(reloaded.themeMode, ThemeMode.dark); + }); +}