diff --git a/README.md b/README.md index 0245c4a9..cca63796 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Create isolated instances (work, personal, client) - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys +- **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles @@ -166,6 +167,8 @@ CCS also persists Droid's active model selector in `~/.factory/settings.json` (`model: custom:`). This avoids passing `-m` argv in interactive mode, which Droid treats as queued prompt text. +Dashboard parity: `ccs config` -> `Factory Droid` + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts new file mode 100644 index 00000000..f18867d6 --- /dev/null +++ b/src/web-server/routes/droid-routes.ts @@ -0,0 +1,71 @@ +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, + getDroidDashboardDiagnostics, + getDroidRawSettings, + saveDroidRawSettings, +} from '../services/droid-dashboard-service'; + +const router = Router(); + +/** + * GET /api/droid/diagnostics + * Dashboard-ready Droid installation + BYOK configuration diagnostics. + */ +router.get('/diagnostics', async (_req: Request, res: Response): Promise => { + try { + res.json(await getDroidDashboardDiagnostics()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/droid/settings/raw + * Raw ~/.factory/settings.json payload for editor. + */ +router.get('/settings/raw', async (_req: Request, res: Response): Promise => { + try { + res.json(await getDroidRawSettings()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/droid/settings/raw + * Save raw ~/.factory/settings.json payload from dashboard editor. + */ +router.put('/settings/raw', async (req: Request, res: Response): Promise => { + try { + const { rawText, expectedMtime } = req.body ?? {}; + + if (typeof rawText !== 'string') { + res.status(400).json({ error: 'rawText must be a string.' }); + return; + } + if ( + expectedMtime !== undefined && + (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) + ) { + res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' }); + return; + } + + res.json(await saveDroidRawSettings({ rawText, expectedMtime })); + } catch (error) { + if (error instanceof DroidRawSettingsValidationError) { + res.status(400).json({ error: error.message }); + return; + } + if (error instanceof DroidRawSettingsConflictError) { + res.status(409).json({ error: error.message, mtime: error.mtime }); + return; + } + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index f9517635..09097156 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -21,6 +21,7 @@ import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; +import droidRoutes from './droid-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -67,6 +68,9 @@ apiRoutes.use('/copilot', copilotRoutes); // ==================== Cursor ==================== apiRoutes.use('/cursor', cursorRoutes); +// ==================== Droid ==================== +apiRoutes.use('/droid', droidRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts new file mode 100644 index 00000000..551fa978 --- /dev/null +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -0,0 +1,105 @@ +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface CompatibleCliDocsReference { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; +} + +interface CompatibleCliDocsRegistryEntry { + cliId: string; + displayName: string; + docsReference: CompatibleCliDocsReference; +} + +const COMPATIBLE_CLI_DOCS_REGISTRY: Record = { + droid: { + cliId: 'droid', + displayName: 'Droid CLI', + docsReference: { + providerValues: ['anthropic', 'openai', 'generic-chat-completion-api'], + settingsHierarchy: [ + 'project-level config', + 'user-level config', + 'home-level config', + 'CLI flags and env vars', + ], + notes: [ + 'BYOK custom models are read from ~/.factory/settings.json customModels[]', + 'Factory docs mention legacy support for ~/.factory/config.json', + 'Interactive model selection uses settings.model (custom:)', + 'droid exec supports --model for one-off execution mode', + ], + links: [ + { + id: 'droid-cli-overview', + label: 'Droid CLI Overview', + url: 'https://docs.factory.ai/cli/', + category: 'overview', + source: 'factory', + description: 'Primary entry docs for setup, auth, and core CLI usage.', + }, + { + id: 'droid-byok-overview', + label: 'BYOK Overview', + url: 'https://docs.factory.ai/cli/byok/overview/', + category: 'byok', + source: 'factory', + description: 'BYOK model/provider shape, provider values, and migration notes.', + }, + { + id: 'droid-settings-reference', + label: 'settings.json Reference', + url: 'https://docs.factory.ai/cli/configuration/settings/', + category: 'configuration', + source: 'factory', + description: 'Supported settings keys, defaults, and allowed values.', + }, + ], + providerDocs: [ + { + provider: 'anthropic', + label: 'Anthropic Messages API', + apiFormat: 'Messages API', + url: 'https://docs.anthropic.com/en/api/messages', + }, + { + provider: 'openai', + label: 'OpenAI Responses API', + apiFormat: 'Responses API', + url: 'https://platform.openai.com/docs/api-reference/responses', + }, + { + provider: 'generic-chat-completion-api', + label: 'OpenAI Chat Completions Spec', + apiFormat: 'Chat Completions API', + url: 'https://platform.openai.com/docs/api-reference/chat', + }, + ], + }, + }, +}; + +export function getCompatibleCliDocsReference(cliId: string): CompatibleCliDocsReference { + const entry = COMPATIBLE_CLI_DOCS_REGISTRY[cliId]; + if (!entry) { + throw new Error(`Unsupported compatible CLI docs reference: ${cliId}`); + } + return entry.docsReference; +} diff --git a/src/web-server/services/compatible-cli-json-file-service.ts b/src/web-server/services/compatible-cli-json-file-service.ts new file mode 100644 index 00000000..eacf3a65 --- /dev/null +++ b/src/web-server/services/compatible-cli-json-file-service.ts @@ -0,0 +1,231 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; + +export interface JsonFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface JsonFileProbe { + diagnostics: JsonFileDiagnostics; + json: Record | null; + rawText: string; +} + +interface WriteJsonObjectFileInput { + filePath: string; + rawText: string; + expectedMtime?: number; + fileLabel?: string; + dirMode?: number; + fileMode?: number; +} + +interface WriteJsonObjectFileResult { + mtime: number; +} + +export class JsonFileValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'JsonFileValidationError'; + } +} + +export class JsonFileConflictError extends Error { + readonly code = 'CONFLICT'; + readonly mtime: number; + + constructor(message: string, mtime: number) { + super(message); + this.name = 'JsonFileConflictError'; + this.mtime = mtime; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function statPath(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } +} + +export async function probeJsonObjectFile( + filePath: string, + label: string, + displayPath: string +): Promise { + const stat = await statPath(filePath); + if (!stat) { + return { + diagnostics: { + label, + path: displayPath, + resolvedPath: filePath, + exists: false, + isSymlink: false, + isRegularFile: false, + sizeBytes: null, + mtimeMs: null, + parseError: null, + readError: null, + }, + json: null, + rawText: '{}', + }; + } + + const diagnostics: JsonFileDiagnostics = { + label, + path: displayPath, + resolvedPath: filePath, + exists: true, + isSymlink: stat.isSymbolicLink(), + isRegularFile: stat.isFile(), + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + parseError: null, + readError: null, + }; + + if (diagnostics.isSymlink) { + diagnostics.readError = 'Refusing symlink file for safety.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + if (!diagnostics.isRegularFile) { + diagnostics.readError = 'Target is not a regular file.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + try { + const rawText = await fs.readFile(filePath, 'utf8'); + try { + const parsed = JSON.parse(rawText); + if (!isObject(parsed)) { + diagnostics.parseError = 'JSON root must be an object.'; + return { diagnostics, json: null, rawText }; + } + return { diagnostics, json: parsed, rawText }; + } catch (error) { + diagnostics.parseError = (error as Error).message; + return { diagnostics, json: null, rawText }; + } + } catch (error) { + diagnostics.readError = (error as Error).message; + return { diagnostics, json: null, rawText: '{}' }; + } +} + +export function parseJsonObjectText( + rawText: string, + fieldName = 'rawText' +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(rawText); + } catch (error) { + throw new JsonFileValidationError(`Invalid JSON in ${fieldName}: ${(error as Error).message}`); + } + + if (!isObject(parsed)) { + throw new JsonFileValidationError(`${fieldName} JSON root must be an object.`); + } + + return parsed; +} + +export async function writeJsonObjectFileAtomic( + input: WriteJsonObjectFileInput +): Promise { + const fileLabel = input.fileLabel || path.basename(input.filePath); + const parsed = parseJsonObjectText(input.rawText, fileLabel); + const targetPath = input.filePath; + const targetDir = path.dirname(targetPath); + const tempPath = targetPath + '.tmp'; + const dirMode = input.dirMode ?? 0o700; + const fileMode = input.fileMode ?? 0o600; + + await fs.mkdir(targetDir, { recursive: true, mode: dirMode }); + + const targetStat = await statPath(targetPath); + if (targetStat) { + const stat = targetStat; + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); + } + if (!stat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`); + } + + if (typeof input.expectedMtime !== 'number' || !Number.isFinite(input.expectedMtime)) { + throw new JsonFileConflictError('File metadata not loaded. Refresh and retry.', stat.mtimeMs); + } + if (Math.abs(stat.mtimeMs - input.expectedMtime) > 1000) { + throw new JsonFileConflictError('File modified externally.', stat.mtimeMs); + } + } + + let wroteTemp = false; + try { + const existingTempStat = await statPath(tempPath); + if (existingTempStat) { + const tempStat = existingTempStat; + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + } + + await fs.writeFile(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); + wroteTemp = true; + + const tempStat = await fs.lstat(tempPath); + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + + await fs.rename(tempPath, targetPath); + wroteTemp = false; + + try { + await fs.chmod(targetPath, fileMode); + } catch { + // Best-effort permission hardening. + } + + const stat = await fs.stat(targetPath); + return { mtime: stat.mtimeMs }; + } finally { + if (wroteTemp) { + try { + await fs.unlink(tempPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + } +} diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts new file mode 100644 index 00000000..6d93c1ce --- /dev/null +++ b/src/web-server/services/compatible-cli-types.ts @@ -0,0 +1,90 @@ +export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing'; + +export interface DroidBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: DroidBinarySource; + version: string | null; + overridePath: string | null; +} + +export interface DroidConfigFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface DroidCustomModelDiagnostics { + displayName: string; + model: string; + provider: string; + baseUrl: string; + host: string | null; + maxOutputTokens: number | null; + isCcsManaged: boolean; + apiKeyState: 'set' | 'missing'; + apiKeyPreview: string | null; +} + +export interface DroidByokDiagnostics { + activeModelSelector: string | null; + customModelCount: number; + ccsManagedCount: number; + userManagedCount: number; + invalidModelEntryCount: number; + providerBreakdown: Record; + customModels: DroidCustomModelDiagnostics[]; +} + +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface CompatibleCliDocsReference { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; +} + +export interface DroidDashboardDiagnostics { + binary: DroidBinaryDiagnostics; + files: { + settings: DroidConfigFileDiagnostics; + legacyConfig: DroidConfigFileDiagnostics; + }; + byok: DroidByokDiagnostics; + warnings: string[]; + docsReference: CompatibleCliDocsReference; +} + +export interface DroidRawSettingsResponse { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts new file mode 100644 index 00000000..ad960f21 --- /dev/null +++ b/src/web-server/services/droid-dashboard-service.ts @@ -0,0 +1,247 @@ +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { detectDroidCli } from '../../targets/droid-detector'; +import type { + DroidByokDiagnostics, + DroidCustomModelDiagnostics, + DroidDashboardDiagnostics, + DroidRawSettingsResponse, +} from './compatible-cli-types'; +import { + JsonFileConflictError, + JsonFileValidationError, + probeJsonObjectFile, + writeJsonObjectFileAtomic, +} from './compatible-cli-json-file-service'; +import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry'; + +interface DroidConfigPaths { + settingsPath: string; + settingsDisplayPath: string; + legacyConfigPath: string; + legacyConfigDisplayPath: string; +} + +interface SaveDroidRawSettingsInput { + rawText: string; + expectedMtime?: number; +} + +interface SaveDroidRawSettingsResult { + success: true; + mtime: number; +} + +export { + JsonFileConflictError as DroidRawSettingsConflictError, + JsonFileValidationError as DroidRawSettingsValidationError, +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function parseHost(value: string): string | null { + try { + return new URL(value).host || null; + } catch { + return null; + } +} + +export function maskApiKeyPreview(value: string): string { + if (!value) return ''; + const suffix = value.slice(-4); + return `***${suffix}`; +} + +function isCcsManagedDisplayName(displayName: string): boolean { + return displayName.startsWith('CCS ') || displayName.startsWith('ccs-'); +} + +export function resolveDroidConfigPaths( + options: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + } = {} +): DroidConfigPaths { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + + const byokBase = env.CCS_HOME || homeDir; + const settingsPath = path.join(byokBase, '.factory', 'settings.json'); + const legacyConfigPath = path.join(byokBase, '.factory', 'config.json'); + + return { + settingsPath, + settingsDisplayPath: '~/.factory/settings.json', + legacyConfigPath, + legacyConfigDisplayPath: '~/.factory/config.json', + }; +} + +function getBinaryVersion(binaryPath: string): string | null { + try { + return execFileSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }) + .trim() + .split('\n')[0] + .trim(); + } catch { + return null; + } +} + +export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics { + const rows: DroidCustomModelDiagnostics[] = []; + const providerBreakdown: Record = {}; + let invalidModelEntryCount = 0; + + const source = Array.isArray(customModelsValue) + ? customModelsValue + : isObject(customModelsValue) + ? Object.values(customModelsValue) + : []; + + for (const item of source) { + if (!isObject(item)) { + invalidModelEntryCount += 1; + continue; + } + + const displayName = asString(item.displayName); + const model = asString(item.model); + const baseUrl = asString(item.baseUrl); + const providerRaw = asString(item.provider); + const apiKey = asString(item.apiKey); + + if (!displayName || !model || !baseUrl || !providerRaw) { + invalidModelEntryCount += 1; + continue; + } + + const provider = providerRaw.toLowerCase(); + providerBreakdown[provider] = (providerBreakdown[provider] ?? 0) + 1; + + rows.push({ + displayName, + model, + provider, + baseUrl, + host: parseHost(baseUrl), + maxOutputTokens: typeof item.maxOutputTokens === 'number' ? item.maxOutputTokens : null, + isCcsManaged: isCcsManagedDisplayName(displayName), + apiKeyState: apiKey ? 'set' : 'missing', + apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null, + }); + } + + const ccsManagedCount = rows.filter((row) => row.isCcsManaged).length; + + return { + activeModelSelector: null, + customModelCount: rows.length, + ccsManagedCount, + userManagedCount: rows.length - ccsManagedCount, + invalidModelEntryCount, + providerBreakdown, + customModels: rows, + }; +} + +export async function getDroidDashboardDiagnostics(): Promise { + const paths = resolveDroidConfigPaths(); + const binaryPath = detectDroidCli(); + const docsReference = getCompatibleCliDocsReference('droid'); + + const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; + + const settingsProbe = await probeJsonObjectFile( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + const legacyConfigProbe = await probeJsonObjectFile( + paths.legacyConfigPath, + 'Legacy config', + paths.legacyConfigDisplayPath + ); + + const byok = summarizeDroidCustomModels(settingsProbe.json?.customModels); + byok.activeModelSelector = asString(settingsProbe.json?.model); + + const warnings: string[] = []; + if (!binaryPath) warnings.push('Droid binary is not detected in PATH or CCS_DROID_PATH.'); + if (settingsProbe.diagnostics.parseError) { + warnings.push('~/.factory/settings.json contains invalid JSON.'); + } + if (byok.invalidModelEntryCount > 0) { + warnings.push(`${byok.invalidModelEntryCount} customModels entries are malformed.`); + } + if (legacyConfigProbe.diagnostics.parseError) { + warnings.push('Legacy Droid config (~/.factory/config.json) JSON is invalid.'); + } + + return { + binary: { + installed: !!binaryPath, + path: binaryPath, + installDir: binaryPath ? path.dirname(binaryPath) : null, + source, + version: binaryPath ? getBinaryVersion(binaryPath) : null, + overridePath: process.env.CCS_DROID_PATH || null, + }, + files: { + settings: settingsProbe.diagnostics, + legacyConfig: legacyConfigProbe.diagnostics, + }, + byok, + warnings, + docsReference, + }; +} + +export async function getDroidRawSettings(): Promise { + const paths = resolveDroidConfigPaths(); + const settingsProbe = await probeJsonObjectFile( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + + return { + path: paths.settingsDisplayPath, + resolvedPath: paths.settingsPath, + exists: settingsProbe.diagnostics.exists, + mtime: settingsProbe.diagnostics.mtimeMs ?? Date.now(), + rawText: settingsProbe.rawText, + settings: settingsProbe.json, + parseError: settingsProbe.diagnostics.parseError, + }; +} + +export async function saveDroidRawSettings( + input: SaveDroidRawSettingsInput +): Promise { + const paths = resolveDroidConfigPaths(); + if (typeof input.rawText !== 'string') { + throw new JsonFileValidationError('rawText must be a string.'); + } + + const saved = await writeJsonObjectFileAtomic({ + filePath: paths.settingsPath, + rawText: input.rawText, + expectedMtime: input.expectedMtime, + fileLabel: 'settings.json', + }); + return { success: true, mtime: saved.mtime }; +} diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts new file mode 100644 index 00000000..50ebbe2a --- /dev/null +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, + getDroidDashboardDiagnostics, + getDroidRawSettings, + maskApiKeyPreview, + resolveDroidConfigPaths, + saveDroidRawSettings, + summarizeDroidCustomModels, +} from '../../../src/web-server/services/droid-dashboard-service'; + +const testRoot = path.join(os.tmpdir(), `ccs-droid-dashboard-test-${Date.now()}`); + +beforeEach(() => { + fs.mkdirSync(testRoot, { recursive: true }); + process.env.CCS_HOME = testRoot; +}); + +afterEach(() => { + delete process.env.CCS_HOME; + if (fs.existsSync(testRoot)) { + fs.rmSync(testRoot, { recursive: true, force: true }); + } +}); + +describe('droid-dashboard-service', () => { + it('resolves droid config paths on unix-like platforms', () => { + const resolved = resolveDroidConfigPaths({ + platform: 'darwin', + env: { + CCS_HOME: '/tmp/ccs-home', + } as NodeJS.ProcessEnv, + homeDir: '/Users/tester', + }); + + expect(resolved.settingsPath).toBe('/tmp/ccs-home/.factory/settings.json'); + expect(resolved.legacyConfigPath).toBe('/tmp/ccs-home/.factory/config.json'); + expect(resolved.settingsDisplayPath).toBe('~/.factory/settings.json'); + expect(resolved.legacyConfigDisplayPath).toBe('~/.factory/config.json'); + }); + + it('resolves droid config paths on windows platforms', () => { + const resolved = resolveDroidConfigPaths({ + platform: 'win32', + env: {} as NodeJS.ProcessEnv, + homeDir: 'C:/Users/test', + }); + + expect(resolved.settingsPath).toBe(path.join('C:/Users/test', '.factory', 'settings.json')); + expect(resolved.legacyConfigPath).toBe(path.join('C:/Users/test', '.factory', 'config.json')); + expect(resolved.legacyConfigDisplayPath).toBe('~/.factory/config.json'); + }); + + it('masks api key preview with only suffix', () => { + expect(maskApiKeyPreview('sk-abcdefghijklmnop')).toBe('***mnop'); + }); + + it('summarizes custom model entries with provider breakdown and ownership', () => { + const summary = summarizeDroidCustomModels([ + { + displayName: 'CCS codex', + model: 'gpt-5-codex', + baseUrl: 'http://127.0.0.1:8317/v1', + apiKey: 'secret-token-1234', + provider: 'openai', + }, + { + displayName: 'Factory team profile', + model: 'claude-sonnet-4-5', + baseUrl: 'https://api.anthropic.com', + apiKey: 'another-token-9999', + provider: 'anthropic', + }, + { + displayName: 'bad entry', + }, + ]); + + expect(summary.customModelCount).toBe(2); + expect(summary.ccsManagedCount).toBe(1); + expect(summary.userManagedCount).toBe(1); + expect(summary.invalidModelEntryCount).toBe(1); + expect(summary.providerBreakdown.openai).toBe(1); + expect(summary.providerBreakdown.anthropic).toBe(1); + expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); + }); + + it('returns raw settings payload for missing settings file', async () => { + const raw = await getDroidRawSettings(); + + expect(raw.exists).toBe(false); + expect(raw.path).toBe('~/.factory/settings.json'); + expect(raw.rawText).toBe('{}'); + expect(raw.settings).toBeNull(); + }); + + it('returns parseError when settings.json is invalid JSON', async () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json'); + + const raw = await getDroidRawSettings(); + + expect(raw.exists).toBe(true); + expect(raw.parseError).toBeString(); + expect(raw.settings).toBeNull(); + expect(raw.rawText).toContain('invalid-json'); + }); + + it('includes structured docs links for fact-checking providers', async () => { + const diagnostics = await getDroidDashboardDiagnostics(); + + expect(diagnostics.docsReference.links.length).toBeGreaterThan(0); + expect(diagnostics.docsReference.providerDocs.length).toBeGreaterThan(0); + expect(diagnostics.docsReference.links.every((link) => link.url.startsWith('https://'))).toBe( + true + ); + expect( + diagnostics.docsReference.providerDocs.some((doc) => doc.provider === 'anthropic') + ).toBe(true); + }); + + it('saves valid raw settings content', async () => { + const result = await saveDroidRawSettings({ + rawText: JSON.stringify({ + model: 'custom:test-model', + customModels: [], + }), + }); + + const settingsPath = path.join(testRoot, '.factory', 'settings.json'); + const written = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + expect(result.success).toBe(true); + expect(result.mtime).toBeGreaterThan(0); + expect(written.model).toBe('custom:test-model'); + }); + + it('rejects invalid JSON while saving raw settings', async () => { + await expect(saveDroidRawSettings({ rawText: '{ invalid-json' })).rejects.toThrow( + DroidRawSettingsValidationError + ); + }); + + it('rejects stale writes with conflict error', async () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] })); + + await expect( + saveDroidRawSettings({ + rawText: JSON.stringify({ model: 'custom:next', customModels: [] }), + expectedMtime: 1, + }) + ).rejects.toThrow(DroidRawSettingsConflictError); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4a823ae4..62ecb304 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -27,6 +27,7 @@ const CliproxyControlPanelPage = lazy(() => ); const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage }))); +const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -117,6 +118,14 @@ export default function App() { } /> + }> + + + } + /> void; + onBooleanSettingChange: (key: DroidBooleanSettingKey, value: boolean | null) => void; + onNumberSettingChange: (key: DroidNumberSettingKey, value: number | null) => void; +} + +const enumFieldConfig: Array<{ + key: DroidEnumSettingKey; + label: string; + description: string; + options: Array<{ value: string; label: string }>; +}> = [ + { + key: 'reasoningEffort', + label: 'Reasoning Effort', + description: 'none | medium | high | max', + options: [ + { value: 'none', label: 'none' }, + { value: 'medium', label: 'medium' }, + { value: 'high', label: 'high' }, + { value: 'max', label: 'max' }, + ], + }, + { + key: 'autonomyLevel', + label: 'Autonomy Level', + description: 'suggest | aggressive | full', + options: [ + { value: 'suggest', label: 'suggest' }, + { value: 'aggressive', label: 'aggressive' }, + { value: 'full', label: 'full' }, + ], + }, + { + key: 'diffMode', + label: 'Diff Mode', + description: 'auto | none | inline | split', + options: [ + { value: 'auto', label: 'auto' }, + { value: 'none', label: 'none' }, + { value: 'inline', label: 'inline' }, + { value: 'split', label: 'split' }, + ], + }, +]; + +const booleanFieldConfig: Array<{ + key: DroidBooleanSettingKey; + label: string; +}> = [ + { key: 'todoEnabled', label: 'Todo Enabled' }, + { key: 'todoAutoRefresh', label: 'Todo Auto Refresh' }, + { key: 'autoCompactEnabled', label: 'Auto Compact Enabled' }, + { key: 'soundEnabled', label: 'Sound Enabled' }, +]; + +const numberFieldConfig: Array<{ + key: DroidNumberSettingKey; + label: string; + min: number; + step: number; +}> = [ + { key: 'maxTurns', label: 'Max Turns', min: 1, step: 1 }, + { key: 'maxToolCalls', label: 'Max Tool Calls', min: 1, step: 1 }, + { key: 'autoCompactThreshold', label: 'Auto Compact Threshold', min: 1000, step: 1000 }, +]; + +function toBooleanSelectValue(value: boolean | null): string { + if (value === true) return 'true'; + if (value === false) return 'false'; + return UNSET_VALUE; +} + +function toBooleanValue(value: string): boolean | null { + if (value === 'true') return true; + if (value === 'false') return false; + return null; +} + +export function DroidSettingsQuickControlsCard({ + values, + disabled, + disabledReason, + onEnumSettingChange, + onBooleanSettingChange, + onNumberSettingChange, +}: DroidSettingsQuickControlsCardProps) { + return ( + + + + + Quick Settings + + settings.json + + + + + {disabledReason &&

{disabledReason}

} + +
+ {enumFieldConfig.map((field) => ( +
+

{field.label}

+ +

{field.description}

+
+ ))} + + {numberFieldConfig.map((field) => ( +
+

{field.label}

+ { + const nextRaw = event.target.value.trim(); + if (!nextRaw) { + onNumberSettingChange(field.key, null); + return; + } + const next = Number.parseInt(nextRaw, 10); + if (!Number.isFinite(next)) return; + onNumberSettingChange(field.key, Math.max(field.min, next)); + }} + className="h-8 text-xs" + disabled={disabled} + /> +
+ ))} + + {booleanFieldConfig.map((field) => ( +
+

{field.label}

+ +
+ ))} +
+
+
+ ); +} diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx new file mode 100644 index 00000000..3b0cf8bf --- /dev/null +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { CodeEditor } from '@/components/shared/code-editor'; +import { cn } from '@/lib/utils'; + +interface RawJsonSettingsEditorPanelProps { + title: string; + pathLabel: string; + loading: boolean; + parseWarning: string | null | undefined; + value: string; + dirty: boolean; + saving: boolean; + saveDisabled: boolean; + onChange: (nextValue: string) => void; + onSave: () => Promise | void; + onRefresh: () => Promise | void; +} + +export function RawJsonSettingsEditorPanel({ + title, + pathLabel, + loading, + parseWarning, + value, + dirty, + saving, + saveDisabled, + onChange, + onSave, + onRefresh, +}: RawJsonSettingsEditorPanelProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!value) return; + await navigator.clipboard.writeText(value); + setCopied(true); + toast.success('Settings copied to clipboard'); + window.setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+
+

+ + {title} + {dirty && ( + + Unsaved + + )} +

+

{pathLabel}

+
+
+ + + +
+
+ +
+ {loading ? ( +
+ + Loading settings.json... +
+ ) : ( +
+ {parseWarning && ( +
+ Parse warning: {parseWarning} +
+ )} +
+
+ +
+
+
+ )} +
+
+ ); +} diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index 47fcc1fa..da504e98 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -11,6 +11,7 @@ import { BarChart3, Gauge, Github, + TerminalSquare, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { @@ -103,6 +104,10 @@ const navGroups: SidebarGroupDef[] = [ }, ], }, + { + title: 'Compatible CLIs', + items: [{ path: '/droid', icon: TerminalSquare, label: 'Factory Droid' }], + }, { title: 'System', items: [ diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts new file mode 100644 index 00000000..7339829e --- /dev/null +++ b/ui/src/hooks/use-droid.ts @@ -0,0 +1,181 @@ +import { useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ApiConflictError, withApiBase } from '@/lib/api-client'; + +export interface DroidBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: 'CCS_DROID_PATH' | 'PATH' | 'missing'; + version: string | null; + overridePath: string | null; +} + +export interface DroidConfigFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface DroidCustomModelDiagnostics { + displayName: string; + model: string; + provider: string; + baseUrl: string; + host: string | null; + maxOutputTokens: number | null; + isCcsManaged: boolean; + apiKeyState: 'set' | 'missing'; + apiKeyPreview: string | null; +} + +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface DroidDashboardDiagnostics { + binary: DroidBinaryDiagnostics; + files: { + settings: DroidConfigFileDiagnostics; + legacyConfig: DroidConfigFileDiagnostics; + }; + byok: { + activeModelSelector: string | null; + customModelCount: number; + ccsManagedCount: number; + userManagedCount: number; + invalidModelEntryCount: number; + providerBreakdown: Record; + customModels: DroidCustomModelDiagnostics[]; + }; + warnings: string[]; + docsReference: { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; + }; +} + +export interface DroidRawSettings { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} + +interface SaveDroidRawSettingsInput { + rawText: string; + expectedMtime?: number; +} + +interface SaveDroidRawSettingsResponse { + success: true; + mtime: number; +} + +async function fetchDroidDiagnostics(): Promise { + const res = await fetch(withApiBase('/droid/diagnostics')); + if (!res.ok) throw new Error('Failed to fetch Droid diagnostics'); + return res.json(); +} + +async function fetchDroidRawSettings(): Promise { + const res = await fetch(withApiBase('/droid/settings/raw')); + if (!res.ok) throw new Error('Failed to fetch Droid raw settings'); + return res.json(); +} + +async function saveDroidRawSettings( + data: SaveDroidRawSettingsInput +): Promise { + const res = await fetch(withApiBase('/droid/settings/raw'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.status === 409) throw new ApiConflictError('Droid raw settings changed externally'); + + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error || 'Failed to save Droid raw settings'); + } + return res.json(); +} + +export function useDroid() { + const queryClient = useQueryClient(); + + const diagnosticsQuery = useQuery({ + queryKey: ['droid-diagnostics'], + queryFn: fetchDroidDiagnostics, + refetchInterval: 10000, + }); + + const rawSettingsQuery = useQuery({ + queryKey: ['droid-raw-settings'], + queryFn: fetchDroidRawSettings, + }); + + const saveRawSettingsMutation = useMutation({ + mutationFn: saveDroidRawSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['droid-diagnostics'] }); + queryClient.invalidateQueries({ queryKey: ['droid-raw-settings'] }); + }, + }); + + return useMemo( + () => ({ + diagnostics: diagnosticsQuery.data, + diagnosticsLoading: diagnosticsQuery.isLoading, + diagnosticsError: diagnosticsQuery.error, + refetchDiagnostics: diagnosticsQuery.refetch, + + rawSettings: rawSettingsQuery.data, + rawSettingsLoading: rawSettingsQuery.isLoading, + rawSettingsError: rawSettingsQuery.error, + refetchRawSettings: rawSettingsQuery.refetch, + + saveRawSettings: saveRawSettingsMutation.mutate, + saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync, + isSavingRawSettings: saveRawSettingsMutation.isPending, + }), + [ + diagnosticsQuery.data, + diagnosticsQuery.isLoading, + diagnosticsQuery.error, + diagnosticsQuery.refetch, + rawSettingsQuery.data, + rawSettingsQuery.isLoading, + rawSettingsQuery.error, + rawSettingsQuery.refetch, + saveRawSettingsMutation.mutate, + saveRawSettingsMutation.mutateAsync, + saveRawSettingsMutation.isPending, + ] + ); +} diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx new file mode 100644 index 00000000..5bef6e73 --- /dev/null +++ b/ui/src/pages/droid.tsx @@ -0,0 +1,605 @@ +import { type ReactNode, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { + AlertTriangle, + CheckCircle2, + ExternalLink, + Folder, + GripVertical, + Loader2, + Server, + ShieldCheck, + TerminalSquare, + XCircle, +} from 'lucide-react'; +import { useDroid } from '@/hooks/use-droid'; +import { isApiConflictError } from '@/lib/api-client'; +import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; +import { + DroidSettingsQuickControlsCard, + type DroidQuickSettingsValues, +} from '@/components/compatible-cli/droid-settings-quick-controls-card'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; + +const DEFAULT_DROID_FACTORY_DOC_LINKS = [ + { + id: 'droid-cli-overview', + label: 'Droid CLI Overview', + url: 'https://docs.factory.ai/cli/', + description: 'Primary entry docs for setup, auth, and core CLI usage.', + }, + { + id: 'droid-byok-overview', + label: 'BYOK Overview', + url: 'https://docs.factory.ai/cli/byok/overview/', + description: 'BYOK model/provider shape, provider values, and migration notes.', + }, + { + id: 'droid-settings-reference', + label: 'settings.json Reference', + url: 'https://docs.factory.ai/cli/configuration/settings/', + description: 'Supported settings keys, defaults, and allowed values.', + }, +]; + +const DEFAULT_DROID_PROVIDER_DOC_LINKS = [ + { + provider: 'anthropic', + label: 'Anthropic Messages API', + apiFormat: 'Messages API', + url: 'https://docs.anthropic.com/en/api/messages', + }, + { + provider: 'openai', + label: 'OpenAI Responses API', + apiFormat: 'Responses API', + url: 'https://platform.openai.com/docs/api-reference/responses', + }, + { + provider: 'generic-chat-completion-api', + label: 'OpenAI Chat Completions Spec', + apiFormat: 'Chat Completions API', + url: 'https://platform.openai.com/docs/api-reference/chat', + }, +]; + +function renderTextWithLinks(text: string): ReactNode[] { + const urlPattern = /https?:\/\/[^\s)]+/g; + const nodes: ReactNode[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = urlPattern.exec(text)) !== null) { + const [url] = match; + const index = match.index; + + if (index > cursor) { + nodes.push(text.slice(cursor, index)); + } + + nodes.push( + + {url} + + ); + cursor = index + url.length; + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +function formatTimestamp(value: number | null | undefined): string { + if (!value || !Number.isFinite(value)) return 'N/A'; + return new Date(value).toLocaleString(); +} + +function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(2)} MB`; +} + +function parseJsonObjectText( + text: string +): { valid: true; value: Record } | { valid: false; error: string } { + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { valid: false, error: 'JSON root must be an object.' }; + } + return { valid: true, value: parsed as Record }; + } catch (error) { + return { valid: false, error: (error as Error).message }; + } +} + +function asStringValue(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asNumberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function asBooleanValue(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + +function DetailRow({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +export function DroidPage() { + const { + diagnostics, + diagnosticsLoading, + diagnosticsError, + refetchDiagnostics, + rawSettings, + rawSettingsLoading, + refetchRawSettings, + saveRawSettingsAsync, + isSavingRawSettings, + } = useDroid(); + + const [rawDraftText, setRawDraftText] = useState(null); + const rawBaseText = rawSettings?.rawText ?? '{}'; + const rawEditorText = rawDraftText ?? rawBaseText; + const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText; + const rawEditorParsed = parseJsonObjectText(rawEditorText); + const rawEditorValidation = rawEditorParsed.valid + ? { valid: true as const } + : { valid: false as const, error: rawEditorParsed.error }; + + const setRawEditorDraftText = (nextText: string) => { + if (nextText === rawBaseText) { + setRawDraftText(null); + return; + } + setRawDraftText(nextText); + }; + + const updateSettingsField = (key: string, value: unknown | null) => { + if (!rawEditorParsed.valid) { + toast.error('Fix JSON syntax before using quick settings controls.'); + return; + } + + const nextSettings = { ...rawEditorParsed.value }; + if (value === null || value === undefined) { + delete nextSettings[key]; + } else { + nextSettings[key] = value; + } + setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n'); + }; + + const quickSettingsValues: DroidQuickSettingsValues = rawEditorParsed.valid + ? { + reasoningEffort: asStringValue(rawEditorParsed.value.reasoningEffort), + autonomyLevel: asStringValue(rawEditorParsed.value.autonomyLevel), + diffMode: asStringValue(rawEditorParsed.value.diffMode), + maxTurns: asNumberValue(rawEditorParsed.value.maxTurns), + maxToolCalls: asNumberValue(rawEditorParsed.value.maxToolCalls), + autoCompactThreshold: asNumberValue(rawEditorParsed.value.autoCompactThreshold), + todoEnabled: asBooleanValue(rawEditorParsed.value.todoEnabled), + todoAutoRefresh: asBooleanValue(rawEditorParsed.value.todoAutoRefresh), + autoCompactEnabled: asBooleanValue(rawEditorParsed.value.autoCompactEnabled), + soundEnabled: asBooleanValue(rawEditorParsed.value.soundEnabled), + } + : { + reasoningEffort: null, + autonomyLevel: null, + diffMode: null, + maxTurns: null, + maxToolCalls: null, + autoCompactThreshold: null, + todoEnabled: null, + todoAutoRefresh: null, + autoCompactEnabled: null, + soundEnabled: null, + }; + + const refreshAll = async () => { + await Promise.all([refetchDiagnostics(), refetchRawSettings()]); + }; + + const handleSaveRawSettings = async () => { + if (!rawEditorValidation.valid) { + toast.error(`Invalid JSON: ${rawEditorValidation.error}`); + return; + } + + try { + await saveRawSettingsAsync({ + rawText: rawEditorText, + expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined, + }); + setRawDraftText(null); + await Promise.all([refetchDiagnostics(), refetchRawSettings()]); + toast.success('Droid settings saved'); + } catch (error) { + if (isApiConflictError(error)) { + toast.error('Droid settings changed externally. Refresh and retry.'); + } else { + toast.error((error as Error).message || 'Failed to save Droid settings'); + } + } + }; + + const customModels = diagnostics?.byok.customModels ?? []; + const providerRows = useMemo( + () => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]), + [diagnostics?.byok.providerBreakdown] + ); + + const renderOverview = () => { + if (diagnosticsLoading) { + return ( +
+ + Loading Droid diagnostics... +
+ ); + } + + if (diagnosticsError || !diagnostics) { + return ( +
+ Failed to load Droid diagnostics. +
+ ); + } + + const docsReference = diagnostics.docsReference ?? { + notes: [], + links: [], + providerDocs: [], + providerValues: [], + settingsHierarchy: [], + }; + const docsNotes = docsReference.notes ?? []; + const docsLinksRaw = docsReference.links ?? []; + const providerDocsRaw = docsReference.providerDocs ?? []; + const docsLinks = docsLinksRaw.length > 0 ? docsLinksRaw : DEFAULT_DROID_FACTORY_DOC_LINKS; + const providerDocs = + providerDocsRaw.length > 0 ? providerDocsRaw : DEFAULT_DROID_PROVIDER_DOC_LINKS; + const providerValues = docsReference.providerValues ?? []; + const settingsHierarchy = docsReference.settingsHierarchy ?? []; + + return ( + +
+ + + + + Runtime & Installation + + + +
+ Status + + {diagnostics.binary.installed ? 'Detected' : 'Not Found'} + +
+ + + + + +
+
+ + + + + + Config Files + + + + {[diagnostics.files.settings, diagnostics.files.legacyConfig].map((file) => ( +
+
+ {file.label} + {file.exists ? ( + + ) : ( + + )} +
+ + + + + {file.parseError && ( +

Parse warning: {file.parseError}

+ )} + {file.readError && ( +

Read warning: {file.readError}

+ )} +
+ ))} +
+
+ + { + updateSettingsField(key, value); + }} + onBooleanSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + onNumberSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + /> + + + + + + BYOK Summary + + + + + + + + + +
+

Providers

+
+ {providerRows.length === 0 && ( + + none + + )} + {providerRows.map(([provider, count]) => ( + + {provider}: {count} + + ))} +
+
+
+
+ + + + + + Docs-Aligned Notes + + + + {docsNotes.map((note, index) => ( +

+ - {renderTextWithLinks(note)} +

+ ))} + +
+

+ Factory Docs +

+ +
+ + + +

+ Provider values: {providerValues.join(', ')} +

+

+ Settings hierarchy: {settingsHierarchy.join(' -> ')} +

+
+
+ + + + Custom Models + + +
+
+ Name / Model + Provider + Base URL +
+ +
+ {customModels.length === 0 && ( +
+ No custom models +
+ )} + {customModels.map((model) => ( +
+
+

{model.displayName}

+

{model.model}

+
+
+

{model.provider}

+

{model.apiKeyPreview || 'no-key'}

+
+
+

+ {model.host || model.baseUrl} +

+

+ {model.baseUrl} +

+
+
+ ))} +
+
+
+
+
+ + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+ ); + }; + + return ( +
+ + +
{renderOverview()}
+
+ + + + + { + setRawEditorDraftText(next); + }} + onSave={handleSaveRawSettings} + onRefresh={refreshAll} + /> + +
+
+ ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index dda9ae12..b4ef665f 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -17,3 +17,5 @@ export { AnalyticsPage } from './analytics'; export { CursorPage } from './cursor'; export { UpdatesPage } from './updates'; + +export { DroidPage } from './droid';