From e9eab712b3f60d1dcf45746df69dabac29beb299 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 23:39:39 +0700 Subject: [PATCH] refactor(dashboard): reuse compatible CLI settings editor stack - add shared backend JSON file probe/write utilities for compatible CLI settings - add shared frontend raw JSON editor panel and wire Droid page to it - support PUT save flow with validation and mtime conflict handling --- src/web-server/routes/droid-routes.ts | 39 +++- .../compatible-cli-json-file-service.ts | 212 ++++++++++++++++++ .../services/droid-dashboard-service.ts | 107 +++------ .../droid-dashboard-service.test.ts | 39 ++++ .../raw-json-settings-editor-panel.tsx | 103 +++++++++ ui/src/hooks/use-droid.ts | 48 +++- ui/src/pages/droid.tsx | 142 ++++++------ 7 files changed, 542 insertions(+), 148 deletions(-) create mode 100644 src/web-server/services/compatible-cli-json-file-service.ts create mode 100644 ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts index 0bbb19e5..8cc81950 100644 --- a/src/web-server/routes/droid-routes.ts +++ b/src/web-server/routes/droid-routes.ts @@ -1,8 +1,11 @@ import type { Request, Response } from 'express'; import { Router } from 'express'; import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, getDroidDashboardDiagnostics, getDroidRawSettings, + saveDroidRawSettings, } from '../services/droid-dashboard-service'; const router = Router(); @@ -21,7 +24,7 @@ router.get('/diagnostics', (_req: Request, res: Response): void => { /** * GET /api/droid/settings/raw - * Raw ~/.factory/settings.json payload for read-only viewer. + * Raw ~/.factory/settings.json payload for editor. */ router.get('/settings/raw', (_req: Request, res: Response): void => { try { @@ -31,4 +34,38 @@ router.get('/settings/raw', (_req: Request, res: Response): void => { } }); +/** + * PUT /api/droid/settings/raw + * Save raw ~/.factory/settings.json payload from dashboard editor. + */ +router.put('/settings/raw', (req: Request, res: Response): void => { + 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(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/services/compatible-cli-json-file-service.ts b/src/web-server/services/compatible-cli-json-file-service.ts new file mode 100644 index 00000000..fe425cf8 --- /dev/null +++ b/src/web-server/services/compatible-cli-json-file-service.ts @@ -0,0 +1,212 @@ +import * 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); +} + +export function probeJsonObjectFile( + filePath: string, + label: string, + displayPath: string +): JsonFileProbe { + if (!fs.existsSync(filePath)) { + 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 stat = fs.lstatSync(filePath); + 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 = fs.readFileSync(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 function writeJsonObjectFileAtomic( + input: WriteJsonObjectFileInput +): WriteJsonObjectFileResult { + 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; + + fs.mkdirSync(targetDir, { recursive: true, mode: dirMode }); + + if (fs.existsSync(targetPath)) { + const stat = fs.lstatSync(targetPath); + 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 { + if (fs.existsSync(tempPath)) { + const tempStat = fs.lstatSync(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.`); + } + } + + fs.writeFileSync(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); + wroteTemp = true; + + const tempStat = fs.lstatSync(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.`); + } + + fs.renameSync(tempPath, targetPath); + wroteTemp = false; + + try { + fs.chmodSync(targetPath, fileMode); + } catch { + // Best-effort permission hardening. + } + + const stat = fs.statSync(targetPath); + return { mtime: stat.mtimeMs }; + } finally { + if (wroteTemp && fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index d342c875..8a0e24a1 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -1,15 +1,19 @@ -import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { execFileSync } from 'child_process'; import { detectDroidCli } from '../../targets/droid-detector'; import type { DroidByokDiagnostics, - DroidConfigFileDiagnostics, DroidCustomModelDiagnostics, DroidDashboardDiagnostics, DroidRawSettingsResponse, } from './compatible-cli-types'; +import { + JsonFileConflictError, + JsonFileValidationError, + probeJsonObjectFile, + writeJsonObjectFileAtomic, +} from './compatible-cli-json-file-service'; interface DroidConfigPaths { settingsPath: string; @@ -18,12 +22,21 @@ interface DroidConfigPaths { legacyConfigDisplayPath: string; } -interface JsonFileProbe { - diagnostics: DroidConfigFileDiagnostics; - json: Record | null; +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); } @@ -87,69 +100,6 @@ function getBinaryVersion(binaryPath: string): string | null { } } -function readJsonFileProbe(filePath: string, label: string, displayPath: string): JsonFileProbe { - if (!fs.existsSync(filePath)) { - 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 stat = fs.lstatSync(filePath); - const diagnostics: DroidConfigFileDiagnostics = { - 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 = fs.readFileSync(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 summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics { const rows: DroidCustomModelDiagnostics[] = []; const providerBreakdown: Record = {}; @@ -213,12 +163,12 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; - const settingsProbe = readJsonFileProbe( + const settingsProbe = probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath ); - const legacyConfigProbe = readJsonFileProbe( + const legacyConfigProbe = probeJsonObjectFile( paths.legacyConfigPath, 'Legacy config', paths.legacyConfigDisplayPath @@ -274,7 +224,7 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { export function getDroidRawSettings(): DroidRawSettingsResponse { const paths = resolveDroidConfigPaths(); - const settingsProbe = readJsonFileProbe( + const settingsProbe = probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath @@ -290,3 +240,18 @@ export function getDroidRawSettings(): DroidRawSettingsResponse { parseError: settingsProbe.diagnostics.parseError, }; } + +export function saveDroidRawSettings(input: SaveDroidRawSettingsInput): SaveDroidRawSettingsResult { + const paths = resolveDroidConfigPaths(); + if (typeof input.rawText !== 'string') { + throw new JsonFileValidationError('rawText must be a string.'); + } + + const saved = 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 index f1f388a7..fcf2542a 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -3,9 +3,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, getDroidRawSettings, maskApiKeyPreview, resolveDroidConfigPaths, + saveDroidRawSettings, summarizeDroidCustomModels, } from '../../../src/web-server/services/droid-dashboard-service'; @@ -106,4 +109,40 @@ describe('droid-dashboard-service', () => { expect(raw.settings).toBeNull(); expect(raw.rawText).toContain('invalid-json'); }); + + it('saves valid raw settings content', () => { + const result = 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', () => { + expect(() => saveDroidRawSettings({ rawText: '{ invalid-json' })).toThrow( + DroidRawSettingsValidationError + ); + }); + + it('rejects stale writes with conflict error', () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] })); + + expect(() => + saveDroidRawSettings({ + rawText: JSON.stringify({ model: 'custom:next', customModels: [] }), + expectedMtime: 1, + }) + ).toThrow(DroidRawSettingsConflictError); + }); }); 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/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index 2a647f6c..a4b2c062 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { withApiBase } from '@/lib/api-client'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ApiConflictError, withApiBase } from '@/lib/api-client'; export interface DroidBinaryDiagnostics { installed: boolean; @@ -69,6 +69,16 @@ export interface DroidRawSettings { 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'); @@ -81,7 +91,26 @@ async function fetchDroidRawSettings(): Promise { 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, @@ -93,6 +122,14 @@ export function useDroid() { queryFn: fetchDroidRawSettings, }); + const saveRawSettingsMutation = useMutation({ + mutationFn: saveDroidRawSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['droid-diagnostics'] }); + queryClient.invalidateQueries({ queryKey: ['droid-raw-settings'] }); + }, + }); + return useMemo( () => ({ diagnostics: diagnosticsQuery.data, @@ -104,6 +141,10 @@ export function useDroid() { rawSettingsLoading: rawSettingsQuery.isLoading, rawSettingsError: rawSettingsQuery.error, refetchRawSettings: rawSettingsQuery.refetch, + + saveRawSettings: saveRawSettingsMutation.mutate, + saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync, + isSavingRawSettings: saveRawSettingsMutation.isPending, }), [ diagnosticsQuery.data, @@ -114,6 +155,9 @@ export function useDroid() { 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 index 3f111297..d53256cb 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -4,24 +4,21 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { AlertTriangle, CheckCircle2, - Copy, - FileCode2, Folder, GripVertical, Loader2, - RefreshCw, Server, ShieldCheck, TerminalSquare, XCircle, } from 'lucide-react'; import { useDroid } from '@/hooks/use-droid'; -import { Button } from '@/components/ui/button'; +import { isApiConflictError } from '@/lib/api-client'; +import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; 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 { CodeEditor } from '@/components/shared/code-editor'; import { cn } from '@/lib/utils'; function formatTimestamp(value: number | null | undefined): string { @@ -36,6 +33,18 @@ function formatBytes(value: number | null | undefined): string { return `${(value / (1024 * 1024)).toFixed(2)} MB`; } +function validateJsonObject(text: string): { valid: true } | { 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 }; + } catch (error) { + return { valid: false, error: (error as Error).message }; + } +} + function DetailRow({ label, value, @@ -62,27 +71,48 @@ export function DroidPage() { rawSettings, rawSettingsLoading, refetchRawSettings, + saveRawSettingsAsync, + isSavingRawSettings, } = useDroid(); - const [copied, setCopied] = useState(false); - - const copyRawSettings = async () => { - if (!rawSettings?.rawText) return; - await navigator.clipboard.writeText(rawSettings.rawText); - setCopied(true); - toast.success('Droid settings copied to clipboard'); - window.setTimeout(() => setCopied(false), 1500); - }; + const [rawDraftText, setRawDraftText] = useState(null); + const rawBaseText = rawSettings?.rawText ?? '{}'; + const rawEditorText = rawDraftText ?? rawBaseText; + const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText; 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 rawEditorValidation = validateJsonObject(rawEditorText); const renderOverview = () => { if (diagnosticsLoading) { @@ -313,66 +343,30 @@ export function DroidPage() { -
-
-
-

- - Droid BYOK Settings -

-

- {rawSettings?.path || '~/.factory/settings.json'} -

-
-
- - -
-
- -
- {rawSettingsLoading ? ( -
- - Loading settings.json... -
- ) : ( -
- {rawSettings?.parseError && ( -
- Parse warning: {rawSettings.parseError} -
- )} -
-
- {}} - language="json" - readonly - minHeight="100%" - /> -
-
-
- )} -
-
+ { + if (next === rawBaseText) { + setRawDraftText(null); + return; + } + setRawDraftText(next); + }} + onSave={handleSaveRawSettings} + onRefresh={refreshAll} + />