mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat(daemon): persist code agent startup (#255)
* feat(daemon): persist code agent startup * fix: complete all suggestions * fix: types for app config * chore: revert local origin * chore: format to single quotes * fix: duplicate headers * fix: isLocalSameOrigin rewriting issue --------- Co-authored-by: mrcfps <mrc@powerformer.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
// Daemon-backed app preferences (onboarding state, agent/skill/DS selection).
|
||||
//
|
||||
// The web frontend pushes non-sensitive preferences here via PUT
|
||||
// /api/app-config; the daemon persists them to <dataDir>/app-config.json
|
||||
// (where dataDir defaults to <projectRoot>/.od but follows OD_DATA_DIR when
|
||||
// set, keeping test and multi-namespace runs isolated).
|
||||
// This survives browser storage resets and origin changes so onboarding
|
||||
// and agent selection don't reappear unexpectedly.
|
||||
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface AgentModelPrefs {
|
||||
model?: string;
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
export interface AppConfigPrefs {
|
||||
onboardingCompleted?: boolean;
|
||||
agentId?: string | null;
|
||||
agentModels?: Record<string, AgentModelPrefs>;
|
||||
skillId?: string | null;
|
||||
designSystemId?: string | null;
|
||||
}
|
||||
|
||||
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
|
||||
'onboardingCompleted',
|
||||
'agentId',
|
||||
'agentModels',
|
||||
'skillId',
|
||||
'designSystemId',
|
||||
] as const);
|
||||
|
||||
function configFile(dataDir: string): string {
|
||||
return path.join(dataDir, 'app-config.json');
|
||||
}
|
||||
|
||||
const AGENT_MODEL_KEYS: ReadonlySet<string> = new Set(['model', 'reasoning']);
|
||||
|
||||
function isValidAgentModelEntry(v: unknown): v is AgentModelPrefs {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
|
||||
const obj = v as Record<string, unknown>;
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (!AGENT_MODEL_KEYS.has(k)) return false;
|
||||
if (obj[k] !== undefined && typeof obj[k] !== 'string') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateAgentModels(
|
||||
raw: unknown,
|
||||
): Record<string, AgentModelPrefs> | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
||||
const result: Record<string, AgentModelPrefs> = Object.create(null);
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (k === '__proto__' || k === 'constructor') continue;
|
||||
if (isValidAgentModelEntry(v)) {
|
||||
result[k] = v;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function applyConfigValue(
|
||||
target: Record<string, unknown>,
|
||||
key: keyof AppConfigPrefs,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (key === 'onboardingCompleted') {
|
||||
if (typeof value === 'boolean') target[key] = value;
|
||||
return;
|
||||
}
|
||||
if (key === 'agentId' || key === 'skillId' || key === 'designSystemId') {
|
||||
if (typeof value === 'string' || value === null) target[key] = value;
|
||||
return;
|
||||
}
|
||||
if (key === 'agentModels') {
|
||||
const validated = validateAgentModels(value);
|
||||
if (validated !== undefined) {
|
||||
target[key] = validated;
|
||||
} else {
|
||||
delete target[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
|
||||
const result: Record<string, unknown> = Object.create(null);
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (ALLOWED_KEYS.has(key as keyof AppConfigPrefs)) {
|
||||
applyConfigValue(result, key as keyof AppConfigPrefs, obj[key]);
|
||||
}
|
||||
}
|
||||
return result as AppConfigPrefs;
|
||||
}
|
||||
|
||||
export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
|
||||
try {
|
||||
const raw = await readFile(configFile(dataDir), 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return filterAllowedKeys(parsed as Record<string, unknown>);
|
||||
}
|
||||
console.warn('[app-config] Invalid shape in config file, returning empty');
|
||||
return {};
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string; name?: string; message?: string };
|
||||
if (e.code === 'ENOENT') return {};
|
||||
if (e.name === 'SyntaxError') {
|
||||
console.error('[app-config] Corrupted JSON, returning empty:', e.message);
|
||||
return {};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize concurrent writes to the same dataDir so the read-modify-write
|
||||
// cycle doesn't lose updates when two PUT requests overlap.
|
||||
const writeLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
export async function writeAppConfig(
|
||||
dataDir: string,
|
||||
partial: Record<string, unknown>,
|
||||
): Promise<AppConfigPrefs> {
|
||||
const prev = writeLocks.get(dataDir) ?? Promise.resolve();
|
||||
const task = prev.catch(() => {}).then(() => doWrite(dataDir, partial));
|
||||
writeLocks.set(dataDir, task);
|
||||
try {
|
||||
return await task;
|
||||
} finally {
|
||||
if (writeLocks.get(dataDir) === task) writeLocks.delete(dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function doWrite(
|
||||
dataDir: string,
|
||||
partial: Record<string, unknown>,
|
||||
): Promise<AppConfigPrefs> {
|
||||
const existing = await readAppConfig(dataDir);
|
||||
const next: Record<string, unknown> = { ...existing };
|
||||
for (const key of Object.keys(partial)) {
|
||||
if (!ALLOWED_KEYS.has(key as keyof AppConfigPrefs)) continue;
|
||||
applyConfigValue(next, key as keyof AppConfigPrefs, partial[key]);
|
||||
}
|
||||
const file = configFile(dataDir);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
const tmp = file + '.' + randomBytes(4).toString('hex') + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
|
||||
await rename(tmp, file);
|
||||
return next as AppConfigPrefs;
|
||||
}
|
||||
+593
-261
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
||||
import http from 'node:http';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
|
||||
import { readAppConfig, writeAppConfig } from '../src/app-config.js';
|
||||
import { isLocalSameOrigin } from '../src/server.js';
|
||||
|
||||
describe('app-config', () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataDir = await mkdtemp(path.join(tmpdir(), 'od-appconfig-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('readAppConfig', () => {
|
||||
it('returns {} when config file does not exist', async () => {
|
||||
expect(await readAppConfig(dataDir)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parsed config from existing file', async () => {
|
||||
await writeFile(
|
||||
path.join(dataDir, 'app-config.json'),
|
||||
JSON.stringify({ onboardingCompleted: true }),
|
||||
);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.onboardingCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it('returns {} for corrupted JSON without crashing', async () => {
|
||||
await writeFile(path.join(dataDir, 'app-config.json'), '{not valid');
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when file contains a JSON array', async () => {
|
||||
await writeFile(path.join(dataDir, 'app-config.json'), '[1,2,3]');
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when file contains a JSON primitive', async () => {
|
||||
await writeFile(path.join(dataDir, 'app-config.json'), '"hello"');
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg).toEqual({});
|
||||
});
|
||||
|
||||
it('filters out unknown keys from stored file', async () => {
|
||||
await writeFile(
|
||||
path.join(dataDir, 'app-config.json'),
|
||||
JSON.stringify({ agentId: 'claude', rogue: 'value', __proto: 'x' }),
|
||||
);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg).toEqual({ agentId: 'claude' });
|
||||
expect(cfg).not.toHaveProperty('rogue');
|
||||
expect(cfg).not.toHaveProperty('__proto');
|
||||
});
|
||||
|
||||
it('filters out invalid scalar values from stored file', async () => {
|
||||
await writeFile(
|
||||
path.join(dataDir, 'app-config.json'),
|
||||
JSON.stringify({
|
||||
onboardingCompleted: 'yes',
|
||||
agentId: 123,
|
||||
skillId: { id: 'bad' },
|
||||
designSystemId: ['bad'],
|
||||
}),
|
||||
);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeAppConfig', () => {
|
||||
it('creates data directory if missing', async () => {
|
||||
const nested = path.join(dataDir, 'sub', 'dir');
|
||||
await writeAppConfig(nested, { onboardingCompleted: true });
|
||||
const cfg = await readAppConfig(nested);
|
||||
expect(cfg.onboardingCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it('only persists ALLOWED_KEYS, filtering unknown keys', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
onboardingCompleted: true,
|
||||
unknownKey: 'should be dropped',
|
||||
agentId: 'claude',
|
||||
});
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg).toEqual({ onboardingCompleted: true, agentId: 'claude' });
|
||||
expect(cfg).not.toHaveProperty('unknownKey');
|
||||
});
|
||||
|
||||
it('does not persist invalid scalar values', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
onboardingCompleted: 'yes',
|
||||
agentId: 123,
|
||||
skillId: false,
|
||||
designSystemId: { id: 'bad' },
|
||||
});
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg).toEqual({});
|
||||
});
|
||||
|
||||
it('merges with existing config', async () => {
|
||||
await writeAppConfig(dataDir, { agentId: 'claude' });
|
||||
await writeAppConfig(dataDir, { skillId: 'coder' });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.agentId).toBe('claude');
|
||||
expect(cfg.skillId).toBe('coder');
|
||||
});
|
||||
|
||||
it('clears a key when null is sent', async () => {
|
||||
await writeAppConfig(dataDir, { agentId: 'claude', skillId: 'coder' });
|
||||
await writeAppConfig(dataDir, { agentId: null });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.agentId).toBeNull();
|
||||
expect(cfg.skillId).toBe('coder');
|
||||
});
|
||||
|
||||
it('clears agentModels when null is sent', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
agentModels: { a: { model: 'gpt-4' } },
|
||||
onboardingCompleted: true,
|
||||
});
|
||||
expect((await readAppConfig(dataDir)).agentModels).toBeDefined();
|
||||
await writeAppConfig(dataDir, { agentModels: null });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.agentModels).toBeUndefined();
|
||||
expect(cfg.onboardingCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it('clears agentModels when empty object is sent', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
agentModels: { a: { model: 'gpt-4' } },
|
||||
});
|
||||
await writeAppConfig(dataDir, { agentModels: {} });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.agentModels).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates agentModels entries, dropping invalid shapes', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
agentModels: {
|
||||
validAgent: { model: 'gpt-4', reasoning: 'fast' },
|
||||
invalidAgent: 'not-an-object',
|
||||
arrayAgent: [1, 2, 3],
|
||||
badKeys: { model: 'ok', extra: 42 },
|
||||
},
|
||||
});
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.agentModels).toEqual({
|
||||
validAgent: { model: 'gpt-4', reasoning: 'fast' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops agentModels entirely when no entries are valid', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
onboardingCompleted: true,
|
||||
agentModels: { bad: 'string-value' },
|
||||
});
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.onboardingCompleted).toBe(true);
|
||||
expect(cfg.agentModels).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles corrupted existing file gracefully on write', async () => {
|
||||
await writeFile(path.join(dataDir, 'app-config.json'), 'CORRUPT');
|
||||
await writeAppConfig(dataDir, { agentId: 'test' });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.agentId).toBe('test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP-layer origin guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function httpRequest(
|
||||
url: string,
|
||||
opts: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: Number(parsed.port),
|
||||
path: parsed.pathname,
|
||||
method: opts.method ?? 'GET',
|
||||
headers: opts.headers ?? {},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => (data += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode!, body: data }));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (opts.body) req.write(opts.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('app-config origin guard', () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.get('/api/app-config', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, port)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
res.json({ config: {} });
|
||||
});
|
||||
app.put('/api/app-config', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, port)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
res.json({ config: req.body });
|
||||
});
|
||||
server = app.listen(0, '127.0.0.1', () => {
|
||||
port = (server.address() as { port: number }).port;
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
it('allows GET from same-origin (no Origin header)', async () => {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
headers: { Host: `127.0.0.1:${port}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('allows PUT from same-origin', async () => {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
body: JSON.stringify({ onboardingCompleted: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects GET with cross-origin Origin header', async () => {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
headers: {
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: 'https://evil.com',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects PUT with cross-origin Origin header', async () => {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: 'https://evil.com',
|
||||
},
|
||||
body: JSON.stringify({ agentId: 'hacked' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects request with wrong Host header', async () => {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
headers: { Host: 'evil.com:9999' },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows GET when Origin is the trusted web port (proxy flow)', async () => {
|
||||
const webPort = port + 1;
|
||||
process.env.OD_WEB_PORT = String(webPort);
|
||||
try {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
headers: {
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: `http://127.0.0.1:${webPort}`,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
delete process.env.OD_WEB_PORT;
|
||||
}
|
||||
});
|
||||
|
||||
it('allows PUT when Origin is the trusted web port (proxy flow)', async () => {
|
||||
const webPort = port + 1;
|
||||
process.env.OD_WEB_PORT = String(webPort);
|
||||
try {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: `http://localhost:${webPort}`,
|
||||
},
|
||||
body: JSON.stringify({ onboardingCompleted: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
delete process.env.OD_WEB_PORT;
|
||||
}
|
||||
});
|
||||
|
||||
it('still rejects cross-origin even when OD_WEB_PORT is set', async () => {
|
||||
process.env.OD_WEB_PORT = String(port + 1);
|
||||
try {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
headers: {
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: 'https://evil.com',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
} finally {
|
||||
delete process.env.OD_WEB_PORT;
|
||||
}
|
||||
});
|
||||
});
|
||||
+118
-43
@@ -4,7 +4,10 @@ import type { CreateInput } from './components/NewProjectPanel';
|
||||
import { PetOverlay } from './components/pet/PetOverlay';
|
||||
import { migrateCustomPetAtlas } from './components/pet/pets';
|
||||
import { ProjectView } from './components/ProjectView';
|
||||
import { SettingsDialog, type SettingsSection } from './components/SettingsDialog';
|
||||
import {
|
||||
SettingsDialog,
|
||||
type SettingsSection,
|
||||
} from './components/SettingsDialog';
|
||||
import {
|
||||
daemonIsLive,
|
||||
fetchAppVersionInfo,
|
||||
@@ -15,10 +18,12 @@ import {
|
||||
} from './providers/registry';
|
||||
import { navigate, useRoute } from './router';
|
||||
import {
|
||||
fetchDaemonConfig,
|
||||
DEFAULT_PET,
|
||||
hasAnyConfiguredProvider,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
syncConfigToDaemon,
|
||||
syncMediaProvidersToDaemon,
|
||||
} from './state/config';
|
||||
import {
|
||||
@@ -44,17 +49,21 @@ export function App() {
|
||||
const [config, setConfig] = useState<AppConfig>(() => loadConfig());
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsWelcome, setSettingsWelcome] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [settingsSection, setSettingsSection] = useState<
|
||||
SettingsSection | undefined
|
||||
>(undefined);
|
||||
const [daemonLive, setDaemonLive] = useState(false);
|
||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
|
||||
const [promptTemplates, setPromptTemplates] = useState<PromptTemplateSummary[]>([]);
|
||||
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(null);
|
||||
const [promptTemplates, setPromptTemplates] = useState<
|
||||
PromptTemplateSummary[]
|
||||
>([]);
|
||||
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(
|
||||
null,
|
||||
);
|
||||
// Goes false once the bootstrap effect has finished its initial round of
|
||||
// fetches. The entry view uses this to show shimmer / skeleton states
|
||||
// instead of an "empty" page that flickers before data lands.
|
||||
@@ -81,18 +90,29 @@ export function App() {
|
||||
const alive = await daemonIsLive();
|
||||
if (cancelled) return;
|
||||
setDaemonLive(alive);
|
||||
const [agentList, skillList, dsList, projectList, templateList, promptTemplateList, versionInfo] =
|
||||
await Promise.all([
|
||||
alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]),
|
||||
alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]),
|
||||
alive
|
||||
? fetchDesignSystems()
|
||||
: Promise.resolve([] as DesignSystemSummary[]),
|
||||
alive ? listProjects() : Promise.resolve([] as Project[]),
|
||||
alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]),
|
||||
alive ? fetchPromptTemplates() : Promise.resolve([] as PromptTemplateSummary[]),
|
||||
alive ? fetchAppVersionInfo() : Promise.resolve(null),
|
||||
]);
|
||||
const [
|
||||
agentList,
|
||||
skillList,
|
||||
dsList,
|
||||
projectList,
|
||||
templateList,
|
||||
promptTemplateList,
|
||||
versionInfo,
|
||||
daemonConfig,
|
||||
] = await Promise.all([
|
||||
alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]),
|
||||
alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]),
|
||||
alive
|
||||
? fetchDesignSystems()
|
||||
: Promise.resolve([] as DesignSystemSummary[]),
|
||||
alive ? listProjects() : Promise.resolve([] as Project[]),
|
||||
alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]),
|
||||
alive
|
||||
? fetchPromptTemplates()
|
||||
: Promise.resolve([] as PromptTemplateSummary[]),
|
||||
alive ? fetchAppVersionInfo() : Promise.resolve(null),
|
||||
alive ? fetchDaemonConfig() : Promise.resolve(null),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setAgents(agentList);
|
||||
setSkills(skillList);
|
||||
@@ -104,14 +124,38 @@ export function App() {
|
||||
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
// Merge daemon-persisted config — daemon values win for the fields
|
||||
// it tracks so that the choice survives origin/storage resets.
|
||||
if (daemonConfig) {
|
||||
if (daemonConfig.onboardingCompleted != null) {
|
||||
next.onboardingCompleted = daemonConfig.onboardingCompleted;
|
||||
}
|
||||
if (daemonConfig.agentId !== undefined) {
|
||||
next.agentId = daemonConfig.agentId;
|
||||
}
|
||||
if (daemonConfig.skillId !== undefined) {
|
||||
next.skillId = daemonConfig.skillId;
|
||||
}
|
||||
if (daemonConfig.designSystemId !== undefined) {
|
||||
next.designSystemId = daemonConfig.designSystemId;
|
||||
}
|
||||
if (daemonConfig.agentModels) {
|
||||
next.agentModels = {
|
||||
...(next.agentModels ?? {}),
|
||||
...daemonConfig.agentModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (alive) {
|
||||
if (!next.agentId) {
|
||||
const firstAvailable = agentList.find((a) => a.available);
|
||||
if (firstAvailable) next.agentId = firstAvailable.id;
|
||||
}
|
||||
if (!next.designSystemId && dsList.length > 0) {
|
||||
next.designSystemId = dsList.find((d) => d.id === 'default')?.id
|
||||
?? dsList[0]!.id;
|
||||
next.designSystemId =
|
||||
dsList.find((d) => d.id === 'default')?.id ?? dsList[0]!.id;
|
||||
}
|
||||
} else {
|
||||
next.mode = 'api';
|
||||
@@ -120,6 +164,12 @@ export function App() {
|
||||
if (alive && hasAnyConfiguredProvider(next.mediaProviders)) {
|
||||
void syncMediaProvidersToDaemon(next.mediaProviders);
|
||||
}
|
||||
// Migrate localStorage prefs to daemon on first boot with the new
|
||||
// endpoint. If daemon already had values the merge above used them;
|
||||
// writing back is idempotent and ensures both sides stay in sync.
|
||||
if (alive) {
|
||||
void syncConfigToDaemon(next);
|
||||
}
|
||||
|
||||
// Pop the onboarding modal only on the first run. Once the user has
|
||||
// saved or skipped past it once, we trust their stored config and
|
||||
@@ -181,7 +231,10 @@ export function App() {
|
||||
// configuration, so future page loads can skip the auto-popup.
|
||||
const withOnboarding: AppConfig = { ...next, onboardingCompleted: true };
|
||||
saveConfig(withOnboarding);
|
||||
void syncMediaProvidersToDaemon(withOnboarding.mediaProviders, { force: true });
|
||||
void syncMediaProvidersToDaemon(withOnboarding.mediaProviders, {
|
||||
force: true,
|
||||
});
|
||||
void syncConfigToDaemon(withOnboarding);
|
||||
setConfig(withOnboarding);
|
||||
setSettingsOpen(false);
|
||||
}, []);
|
||||
@@ -199,6 +252,7 @@ export function App() {
|
||||
(agentId: string) => {
|
||||
const next = { ...config, agentId };
|
||||
saveConfig(next);
|
||||
void syncConfigToDaemon(next);
|
||||
setConfig(next);
|
||||
},
|
||||
[config],
|
||||
@@ -208,9 +262,13 @@ export function App() {
|
||||
(agentId: string, choice: { model?: string; reasoning?: string }) => {
|
||||
const prev = config.agentModels?.[agentId] ?? {};
|
||||
const merged = { ...prev, ...choice };
|
||||
const nextAgentModels = { ...(config.agentModels ?? {}), [agentId]: merged };
|
||||
const nextAgentModels = {
|
||||
...(config.agentModels ?? {}),
|
||||
[agentId]: merged,
|
||||
};
|
||||
const next = { ...config, agentModels: nextAgentModels };
|
||||
saveConfig(next);
|
||||
void syncConfigToDaemon(next);
|
||||
setConfig(next);
|
||||
},
|
||||
[config],
|
||||
@@ -220,6 +278,7 @@ export function App() {
|
||||
(designSystemId: string) => {
|
||||
const next = { ...config, designSystemId };
|
||||
saveConfig(next);
|
||||
void syncConfigToDaemon(next);
|
||||
setConfig(next);
|
||||
},
|
||||
[config],
|
||||
@@ -244,8 +303,15 @@ export function App() {
|
||||
metadata: input.metadata,
|
||||
});
|
||||
if (!result) return;
|
||||
setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]);
|
||||
navigate({ kind: 'project', projectId: result.project.id, fileName: null });
|
||||
setProjects((curr) => [
|
||||
result.project,
|
||||
...curr.filter((p) => p.id !== result.project.id),
|
||||
]);
|
||||
navigate({
|
||||
kind: 'project',
|
||||
projectId: result.project.id,
|
||||
fileName: null,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -253,7 +319,10 @@ export function App() {
|
||||
const handleImportClaudeDesign = useCallback(async (file: File) => {
|
||||
const result = await importClaudeDesignZip(file);
|
||||
if (!result) return;
|
||||
setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]);
|
||||
setProjects((curr) => [
|
||||
result.project,
|
||||
...curr.filter((p) => p.id !== result.project.id),
|
||||
]);
|
||||
navigate({
|
||||
kind: 'project',
|
||||
projectId: result.project.id,
|
||||
@@ -265,22 +334,24 @@ export function App() {
|
||||
navigate({ kind: 'project', projectId: id, fileName: null });
|
||||
}, []);
|
||||
|
||||
const handleDeleteProject = useCallback(async (id: string) => {
|
||||
const ok = await deleteProjectApi(id);
|
||||
if (!ok) return;
|
||||
setProjects((curr) => curr.filter((p) => p.id !== id));
|
||||
if (route.kind === 'project' && route.projectId === id) {
|
||||
navigate({ kind: 'home' });
|
||||
}
|
||||
}, [route]);
|
||||
const handleDeleteProject = useCallback(
|
||||
async (id: string) => {
|
||||
const ok = await deleteProjectApi(id);
|
||||
if (!ok) return;
|
||||
setProjects((curr) => curr.filter((p) => p.id !== id));
|
||||
if (route.kind === 'project' && route.projectId === id) {
|
||||
navigate({ kind: 'home' });
|
||||
}
|
||||
},
|
||||
[route],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate({ kind: 'home' });
|
||||
}, []);
|
||||
|
||||
const handleClearPendingPrompt = useCallback(() => {
|
||||
const projectId =
|
||||
route.kind === 'project' ? route.projectId : null;
|
||||
const projectId = route.kind === 'project' ? route.projectId : null;
|
||||
if (!projectId) return;
|
||||
setProjects((curr) =>
|
||||
curr.map((p) =>
|
||||
@@ -291,8 +362,7 @@ export function App() {
|
||||
}, [route]);
|
||||
|
||||
const handleTouchProject = useCallback(() => {
|
||||
const projectId =
|
||||
route.kind === 'project' ? route.projectId : null;
|
||||
const projectId = route.kind === 'project' ? route.projectId : null;
|
||||
if (!projectId) return;
|
||||
const updatedAt = Date.now();
|
||||
setProjects((curr) =>
|
||||
@@ -302,14 +372,12 @@ export function App() {
|
||||
}, [route]);
|
||||
|
||||
const handleProjectChange = useCallback((updated: Project) => {
|
||||
setProjects((curr) =>
|
||||
curr.map((p) => (p.id === updated.id ? updated : p)),
|
||||
);
|
||||
setProjects((curr) => curr.map((p) => (p.id === updated.id ? updated : p)));
|
||||
}, []);
|
||||
|
||||
const activeProject =
|
||||
route.kind === 'project'
|
||||
? projects.find((p) => p.id === route.projectId) ?? null
|
||||
? (projects.find((p) => p.id === route.projectId) ?? null)
|
||||
: null;
|
||||
|
||||
// Deep-linked route to a project we don't have yet (e.g. after a refresh
|
||||
@@ -359,13 +427,19 @@ export function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTuckPet = useCallback(() => handleSetPetEnabled(false), [handleSetPetEnabled]);
|
||||
const handleTuckPet = useCallback(
|
||||
() => handleSetPetEnabled(false),
|
||||
[handleSetPetEnabled],
|
||||
);
|
||||
|
||||
// Toggle wake/tuck — used by the pet rail and the composer button.
|
||||
const handleTogglePet = useCallback(() => {
|
||||
setConfig((curr) => {
|
||||
const prev = curr.pet ?? DEFAULT_PET;
|
||||
const next: AppConfig = { ...curr, pet: { ...prev, enabled: !prev.enabled } };
|
||||
const next: AppConfig = {
|
||||
...curr,
|
||||
pet: { ...prev, enabled: !prev.enabled },
|
||||
};
|
||||
saveConfig(next);
|
||||
return next;
|
||||
});
|
||||
@@ -465,6 +539,7 @@ export function App() {
|
||||
if (settingsWelcome && !config.onboardingCompleted) {
|
||||
const next: AppConfig = { ...config, onboardingCompleted: true };
|
||||
saveConfig(next);
|
||||
void syncConfigToDaemon(next);
|
||||
setConfig(next);
|
||||
}
|
||||
setSettingsOpen(false);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { AppConfigPrefs } from '@open-design/contracts';
|
||||
import { isOpenAICompatible } from '../providers/openai-compatible';
|
||||
import type { ApiProtocol, AppConfig, MediaProviderCredentials, PetConfig } from '../types';
|
||||
import type {
|
||||
ApiProtocol,
|
||||
AppConfig,
|
||||
MediaProviderCredentials,
|
||||
PetConfig,
|
||||
} from '../types';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
const CONFIG_MIGRATION_VERSION = 1;
|
||||
@@ -74,7 +80,12 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [
|
||||
protocol: 'anthropic',
|
||||
baseUrl: 'https://api.deepseek.com/anthropic',
|
||||
model: 'deepseek-chat',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
models: [
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-v4-flash',
|
||||
'deepseek-v4-pro',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax — Anthropic',
|
||||
@@ -103,7 +114,12 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [
|
||||
protocol: 'openai',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
models: [
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-v4-flash',
|
||||
'deepseek-v4-pro',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax — OpenAI',
|
||||
@@ -163,7 +179,10 @@ export function loadConfig(): AppConfig {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { ...DEFAULT_CONFIG, pet: normalizePet(DEFAULT_PET) };
|
||||
const parsed = JSON.parse(raw) as Partial<AppConfig>;
|
||||
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(parsed, 'apiProtocol');
|
||||
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(
|
||||
parsed,
|
||||
'apiProtocol',
|
||||
);
|
||||
const merged: AppConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...parsed,
|
||||
@@ -183,7 +202,9 @@ export function loadConfig(): AppConfig {
|
||||
// whether the user is on a known provider and switch defaults appropriately.
|
||||
// null means "custom/unknown provider" so the protocol switch won't override
|
||||
// their custom base URL.
|
||||
const knownProvider = KNOWN_PROVIDERS.find((p) => p.baseUrl === merged.baseUrl);
|
||||
const knownProvider = KNOWN_PROVIDERS.find(
|
||||
(p) => p.baseUrl === merged.baseUrl,
|
||||
);
|
||||
merged.apiProviderBaseUrl = knownProvider?.baseUrl ?? null;
|
||||
}
|
||||
merged.configMigrationVersion = CONFIG_MIGRATION_VERSION;
|
||||
@@ -223,3 +244,33 @@ export async function syncMediaProvidersToDaemon(
|
||||
// Daemon offline; localStorage keeps the user's copy for the next save.
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDaemonConfig(): Promise<AppConfigPrefs | null> {
|
||||
try {
|
||||
const res = await fetch('/api/app-config');
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data?.config ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncConfigToDaemon(config: AppConfig): Promise<void> {
|
||||
const prefs: AppConfigPrefs = {
|
||||
onboardingCompleted: config.onboardingCompleted,
|
||||
agentId: config.agentId,
|
||||
agentModels: config.agentModels,
|
||||
skillId: config.skillId,
|
||||
designSystemId: config.designSystemId,
|
||||
};
|
||||
try {
|
||||
await fetch('/api/app-config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(prefs),
|
||||
});
|
||||
} catch {
|
||||
// Daemon offline; localStorage keeps the user's copy for the next save.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
AgentModelPrefs,
|
||||
AppVersionInfo,
|
||||
AppVersionResponse,
|
||||
AudioKind,
|
||||
@@ -46,10 +47,7 @@ export interface MediaProviderCredentials {
|
||||
// keeps its own slot so flipping between Codex and Gemini doesn't reset the
|
||||
// other one's choice. Missing entries fall back to the agent's first
|
||||
// declared model (`'default'` — let the CLI pick).
|
||||
export interface AgentModelChoice {
|
||||
model?: string;
|
||||
reasoning?: string;
|
||||
}
|
||||
export type AgentModelChoice = AgentModelPrefs;
|
||||
|
||||
export type AppTheme = 'system' | 'light' | 'dark';
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface AgentModelPrefs {
|
||||
model?: string;
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
export interface AppConfigPrefs {
|
||||
onboardingCompleted?: boolean;
|
||||
agentId?: string | null;
|
||||
agentModels?: Record<string, AgentModelPrefs>;
|
||||
skillId?: string | null;
|
||||
designSystemId?: string | null;
|
||||
}
|
||||
|
||||
export interface AppConfigResponse {
|
||||
config: AppConfigPrefs;
|
||||
}
|
||||
|
||||
export type UpdateAppConfigRequest = Partial<AppConfigPrefs>;
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './common';
|
||||
export * from './errors';
|
||||
export * from './tasks';
|
||||
export * from './api/app-config';
|
||||
export * from './api/artifacts';
|
||||
export * from './api/chat';
|
||||
export * from './api/comments';
|
||||
|
||||
Reference in New Issue
Block a user