mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 06:15:03 +00:00
Merge pull request #635 from kaitranntt/kai/feat/640-droid-dashboard-observability
feat(dashboard): add dedicated Factory Droid diagnostics page
This commit is contained in:
15 files changed
+2032
No files matched your search
@@ -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:<alias>`). 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:
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<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(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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, CompatibleCliDocsRegistryEntry> = {
|
||||
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:<alias>)',
|
||||
'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;
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function statPath(filePath: string): Promise<import('fs').Stats | null> {
|
||||
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<JsonFileProbe> {
|
||||
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<string, unknown> {
|
||||
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<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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
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<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, number> = {};
|
||||
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<DroidDashboardDiagnostics> {
|
||||
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<DroidRawSettingsResponse> {
|
||||
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<SaveDroidRawSettingsResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/droid"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<DroidPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Quick Settings
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
settings.json
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{disabledReason && <p className="text-xs text-amber-600">{disabledReason}</p>}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{enumFieldConfig.map((field) => (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<p className="text-xs font-medium">{field.label}</p>
|
||||
<Select
|
||||
value={values[field.key] ?? UNSET_VALUE}
|
||||
onValueChange={(next) =>
|
||||
onEnumSettingChange(field.key, next === UNSET_VALUE ? null : next)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Use default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET_VALUE}>Use default</SelectItem>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">{field.description}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{numberFieldConfig.map((field) => (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<p className="text-xs font-medium">{field.label}</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={field.min}
|
||||
step={field.step}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={(event) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{booleanFieldConfig.map((field) => (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<p className="text-xs font-medium">{field.label}</p>
|
||||
<Select
|
||||
value={toBooleanSelectValue(values[field.key])}
|
||||
onValueChange={(next) => onBooleanSettingChange(field.key, toBooleanValue(next))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Use default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNSET_VALUE}>Use default</SelectItem>
|
||||
<SelectItem value="true">true</SelectItem>
|
||||
<SelectItem value="false">false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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> | void;
|
||||
onRefresh: () => Promise<void> | 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 (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 border-b bg-background flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<FileCode2 className="h-4 w-4 text-primary" />
|
||||
{title}
|
||||
{dirty && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{pathLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={onSave} disabled={saveDisabled}>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy} disabled={!value}>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading ? 'animate-spin' : '')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading settings.json...
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col">
|
||||
{parseWarning && (
|
||||
<div className="mx-4 mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
|
||||
Parse warning: {parseWarning}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 p-4 pt-3">
|
||||
<div className="h-full rounded-md border overflow-hidden bg-background">
|
||||
<CodeEditor value={value} onChange={onChange} language="json" minHeight="100%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<string, number>;
|
||||
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<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
}
|
||||
|
||||
interface SaveDroidRawSettingsInput {
|
||||
rawText: string;
|
||||
expectedMtime?: number;
|
||||
}
|
||||
|
||||
interface SaveDroidRawSettingsResponse {
|
||||
success: true;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
async function fetchDroidDiagnostics(): Promise<DroidDashboardDiagnostics> {
|
||||
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<DroidRawSettings> {
|
||||
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<SaveDroidRawSettingsResponse> {
|
||||
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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<a
|
||||
key={`${url}-${index}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
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<string, unknown> } | { 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<string, unknown> };
|
||||
} 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 (
|
||||
<div className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={cn('text-right break-all', mono && 'font-mono text-xs')}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DroidPage() {
|
||||
const {
|
||||
diagnostics,
|
||||
diagnosticsLoading,
|
||||
diagnosticsError,
|
||||
refetchDiagnostics,
|
||||
rawSettings,
|
||||
rawSettingsLoading,
|
||||
refetchRawSettings,
|
||||
saveRawSettingsAsync,
|
||||
isSavingRawSettings,
|
||||
} = useDroid();
|
||||
|
||||
const [rawDraftText, setRawDraftText] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading Droid diagnostics...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (diagnosticsError || !diagnostics) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-destructive px-6 text-center">
|
||||
Failed to load Droid diagnostics.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
Runtime & Installation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
|
||||
{diagnostics.binary.installed ? 'Detected' : 'Not Found'}
|
||||
</Badge>
|
||||
</div>
|
||||
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
|
||||
<DetailRow
|
||||
label="Binary path"
|
||||
value={diagnostics.binary.path || 'Not detected'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Install directory"
|
||||
value={diagnostics.binary.installDir || 'N/A'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow label="Version" value={diagnostics.binary.version || 'Unknown'} mono />
|
||||
<DetailRow
|
||||
label="Override (CCS_DROID_PATH)"
|
||||
value={diagnostics.binary.overridePath || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
Config Files
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[diagnostics.files.settings, diagnostics.files.legacyConfig].map((file) => (
|
||||
<div key={file.label} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">{file.label}</span>
|
||||
{file.exists ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<DetailRow label="Path" value={file.path} mono />
|
||||
<DetailRow label="Resolved" value={file.resolvedPath} mono />
|
||||
<DetailRow label="Size" value={formatBytes(file.sizeBytes)} />
|
||||
<DetailRow label="Last modified" value={formatTimestamp(file.mtimeMs)} />
|
||||
{file.parseError && (
|
||||
<p className="text-xs text-amber-600">Parse warning: {file.parseError}</p>
|
||||
)}
|
||||
{file.readError && (
|
||||
<p className="text-xs text-destructive">Read warning: {file.readError}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DroidSettingsQuickControlsCard
|
||||
values={quickSettingsValues}
|
||||
disabled={rawSettingsLoading || !rawEditorParsed.valid}
|
||||
disabledReason={
|
||||
rawEditorParsed.valid ? null : `Quick settings disabled: ${rawEditorParsed.error}`
|
||||
}
|
||||
onEnumSettingChange={(key, value) => {
|
||||
updateSettingsField(key, value);
|
||||
}}
|
||||
onBooleanSettingChange={(key, value) => {
|
||||
updateSettingsField(key, value);
|
||||
}}
|
||||
onNumberSettingChange={(key, value) => {
|
||||
updateSettingsField(key, value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
BYOK Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<DetailRow
|
||||
label="Active model selector"
|
||||
value={diagnostics.byok.activeModelSelector || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow label="Custom models" value={String(diagnostics.byok.customModelCount)} />
|
||||
<DetailRow label="CCS-managed" value={String(diagnostics.byok.ccsManagedCount)} />
|
||||
<DetailRow label="User-managed" value={String(diagnostics.byok.userManagedCount)} />
|
||||
<DetailRow
|
||||
label="Malformed entries"
|
||||
value={String(diagnostics.byok.invalidModelEntryCount)}
|
||||
/>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Providers</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{providerRows.length === 0 && (
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
none
|
||||
</Badge>
|
||||
)}
|
||||
{providerRows.map(([provider, count]) => (
|
||||
<Badge key={provider} variant="outline" className="font-mono text-xs">
|
||||
{provider}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Docs-Aligned Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{docsNotes.map((note, index) => (
|
||||
<p key={`${index}-${note}`} className="text-muted-foreground">
|
||||
- {renderTextWithLinks(note)}
|
||||
</p>
|
||||
))}
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Factory Docs
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{docsLinks.map((link) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{link.label}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">{link.description}</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
|
||||
{link.url}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Provider Fact-Check Docs
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{providerDocs.map((providerDoc) => (
|
||||
<a
|
||||
key={`${providerDoc.provider}-${providerDoc.url}`}
|
||||
href={providerDoc.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{providerDoc.label}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
provider: {providerDoc.provider} | format: {providerDoc.apiFormat}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
|
||||
{providerDoc.url}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provider values: {providerValues.join(', ')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Settings hierarchy: {settingsHierarchy.join(' -> ')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Custom Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<div className="grid grid-cols-[2fr_1fr_2fr] bg-muted/40 px-3 py-2 text-xs font-medium">
|
||||
<span>Name / Model</span>
|
||||
<span>Provider</span>
|
||||
<span>Base URL</span>
|
||||
</div>
|
||||
<ScrollArea className="h-52">
|
||||
<div className="divide-y">
|
||||
{customModels.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground">
|
||||
No custom models
|
||||
</div>
|
||||
)}
|
||||
{customModels.map((model) => (
|
||||
<div
|
||||
key={`${model.displayName}-${model.model}-${model.baseUrl}`}
|
||||
className="grid grid-cols-[2fr_1fr_2fr] gap-2 px-3 py-2 text-xs"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{model.displayName}</p>
|
||||
<p className="text-muted-foreground font-mono truncate">{model.model}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate">{model.provider}</p>
|
||||
<p className="text-muted-foreground">{model.apiKeyPreview || 'no-key'}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate" title={model.baseUrl}>
|
||||
{model.host || model.baseUrl}
|
||||
</p>
|
||||
<p className="text-muted-foreground font-mono truncate">
|
||||
{model.baseUrl}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{diagnostics.warnings.length > 0 && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
Warnings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
{diagnostics.warnings.map((warning) => (
|
||||
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||
- {warning}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] overflow-hidden">
|
||||
<PanelGroup direction="horizontal" className="h-full">
|
||||
<Panel defaultSize={45} minSize={35}>
|
||||
<div className="h-full border-r bg-muted/20">{renderOverview()}</div>
|
||||
</Panel>
|
||||
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
|
||||
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
|
||||
</PanelResizeHandle>
|
||||
<Panel defaultSize={55} minSize={35}>
|
||||
<RawJsonSettingsEditorPanel
|
||||
title="Droid BYOK Settings"
|
||||
pathLabel={rawSettings?.path || '~/.factory/settings.json'}
|
||||
loading={rawSettingsLoading}
|
||||
parseWarning={rawSettings?.parseError}
|
||||
value={rawEditorText}
|
||||
dirty={rawConfigDirty}
|
||||
saving={isSavingRawSettings}
|
||||
saveDisabled={
|
||||
!rawConfigDirty ||
|
||||
isSavingRawSettings ||
|
||||
rawSettingsLoading ||
|
||||
!rawEditorValidation.valid
|
||||
}
|
||||
onChange={(next) => {
|
||||
setRawEditorDraftText(next);
|
||||
}}
|
||||
onSave={handleSaveRawSettings}
|
||||
onRefresh={refreshAll}
|
||||
/>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,3 +17,5 @@ export { AnalyticsPage } from './analytics';
|
||||
export { CursorPage } from './cursor';
|
||||
|
||||
export { UpdatesPage } from './updates';
|
||||
|
||||
export { DroidPage } from './droid';
|
||||
Reference in new issue
Block a user