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-types.ts b/src/web-server/services/compatible-cli-types.ts index bb9898e2..6d93c1ce 100644 --- a/src/web-server/services/compatible-cli-types.ts +++ b/src/web-server/services/compatible-cli-types.ts @@ -44,6 +44,30 @@ export interface DroidByokDiagnostics { 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: { @@ -52,11 +76,7 @@ export interface DroidDashboardDiagnostics { }; byok: DroidByokDiagnostics; warnings: string[]; - docsReference: { - providerValues: string[]; - settingsHierarchy: string[]; - notes: string[]; - }; + docsReference: CompatibleCliDocsReference; } export interface DroidRawSettingsResponse { diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index 903c56ab..ad960f21 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -14,6 +14,7 @@ import { probeJsonObjectFile, writeJsonObjectFileAtomic, } from './compatible-cli-json-file-service'; +import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry'; interface DroidConfigPaths { settingsPath: string; @@ -160,6 +161,7 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo 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'; @@ -204,21 +206,7 @@ export async function getDroidDashboardDiagnostics(): Promise)', - 'droid exec supports --model for one-off execution mode', - ], - }, + docsReference, }; } diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index c781785f..50ebbe2a 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { DroidRawSettingsConflictError, DroidRawSettingsValidationError, + getDroidDashboardDiagnostics, getDroidRawSettings, maskApiKeyPreview, resolveDroidConfigPaths, @@ -110,6 +111,19 @@ describe('droid-dashboard-service', () => { 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({ diff --git a/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx b/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx new file mode 100644 index 00000000..80f43ec7 --- /dev/null +++ b/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx @@ -0,0 +1,214 @@ +import { SlidersHorizontal } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +const UNSET_VALUE = '__unset__'; + +type DroidEnumSettingKey = 'reasoningEffort' | 'autonomyLevel' | 'diffMode'; +type DroidBooleanSettingKey = + | 'todoEnabled' + | 'todoAutoRefresh' + | 'autoCompactEnabled' + | 'soundEnabled'; +type DroidNumberSettingKey = 'maxTurns' | 'maxToolCalls' | 'autoCompactThreshold'; + +export interface DroidQuickSettingsValues { + reasoningEffort: string | null; + autonomyLevel: string | null; + diffMode: string | null; + maxTurns: number | null; + maxToolCalls: number | null; + autoCompactThreshold: number | null; + todoEnabled: boolean | null; + todoAutoRefresh: boolean | null; + autoCompactEnabled: boolean | null; + soundEnabled: boolean | null; +} + +interface DroidSettingsQuickControlsCardProps { + values: DroidQuickSettingsValues; + disabled: boolean; + disabledReason?: string | null; + onEnumSettingChange: (key: DroidEnumSettingKey, value: string | null) => 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/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index a4b2c062..7339829e 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -36,6 +36,22 @@ export interface DroidCustomModelDiagnostics { 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: { @@ -56,6 +72,8 @@ export interface DroidDashboardDiagnostics { providerValues: string[]; settingsHierarchy: string[]; notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; }; } diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index d53256cb..796eb075 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -4,6 +4,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { AlertTriangle, CheckCircle2, + ExternalLink, Folder, GripVertical, Loader2, @@ -15,6 +16,10 @@ import { 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'; @@ -33,18 +38,32 @@ function formatBytes(value: number | null | undefined): string { return `${(value / (1024 * 1024)).toFixed(2)} MB`; } -function validateJsonObject(text: string): { valid: true } | { valid: false; error: string } { +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 }; + 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, @@ -79,6 +98,59 @@ export function DroidPage() { 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()]); @@ -112,7 +184,6 @@ export function DroidPage() { () => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]), [diagnostics?.byok.providerBreakdown] ); - const rawEditorValidation = validateJsonObject(rawEditorText); const renderOverview = () => { if (diagnosticsLoading) { @@ -202,6 +273,23 @@ export function DroidPage() { + { + updateSettingsField(key, value); + }} + onBooleanSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + onNumberSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + /> + @@ -255,6 +343,54 @@ export function DroidPage() {

))} +
+

+ Factory Docs +

+
+ {diagnostics.docsReference.links.map((link) => ( + +
+ {link.label} + +
+

{link.description}

+
+ ))} +
+
+ +
+

+ Provider Fact-Check Docs +

+
+ {diagnostics.docsReference.providerDocs.map((providerDoc) => ( + +
+ {providerDoc.label} + +
+

+ provider: {providerDoc.provider} | format: {providerDoc.apiFormat} +

+
+ ))} +
+
+

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

@@ -358,11 +494,7 @@ export function DroidPage() { !rawEditorValidation.valid } onChange={(next) => { - if (next === rawBaseText) { - setRawDraftText(null); - return; - } - setRawDraftText(next); + setRawEditorDraftText(next); }} onSave={handleSaveRawSettings} onRefresh={refreshAll}