mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* add daemon project location support * wire project locations into web settings * localize project location settings * move default project location to settings * polish project location selection cards * fix project location i18n gaps * fix external project validation cleanup
827 lines
26 KiB
TypeScript
827 lines
26 KiB
TypeScript
import type { AppConfigPrefs } from '@open-design/contracts';
|
|
import { MEDIA_PROVIDERS } from '../media/models';
|
|
import { isOpenAICompatible } from '../providers/openai-compatible';
|
|
import type {
|
|
ApiProtocol,
|
|
AppConfig,
|
|
MediaProviderCredentials,
|
|
NotificationsConfig,
|
|
OrbitConfig,
|
|
PetConfig,
|
|
} from '../types';
|
|
import {
|
|
DEFAULT_ACCENT_COLOR,
|
|
normalizeAccentColor,
|
|
} from './appearance';
|
|
import {
|
|
DEFAULT_FAILURE_SOUND_ID,
|
|
DEFAULT_SUCCESS_SOUND_ID,
|
|
} from '../utils/notifications';
|
|
|
|
const STORAGE_KEY = 'open-design:config';
|
|
const CONFIG_MIGRATION_VERSION = 1;
|
|
|
|
// Hatched out of the box, but tucked away — the user has to go through
|
|
// either the entry-view "adopt a pet" callout or Settings → Pets to
|
|
// summon them. Keeps the workspace quiet for first-run users.
|
|
// Both switches default off so first-run users are not greeted by a
|
|
// surprise sound or a permission prompt; they can opt in from Settings →
|
|
// Notifications when they want it.
|
|
export const DEFAULT_NOTIFICATIONS: NotificationsConfig = {
|
|
soundEnabled: false,
|
|
successSoundId: DEFAULT_SUCCESS_SOUND_ID,
|
|
failureSoundId: DEFAULT_FAILURE_SOUND_ID,
|
|
desktopEnabled: false,
|
|
};
|
|
|
|
export const DEFAULT_PET: PetConfig = {
|
|
adopted: false,
|
|
enabled: false,
|
|
petId: 'mochi',
|
|
custom: {
|
|
name: 'Buddy',
|
|
glyph: '🦄',
|
|
accent: '#c96442',
|
|
greeting: 'Hi! I am here whenever you need me.',
|
|
},
|
|
};
|
|
|
|
export const DEFAULT_ORBIT: OrbitConfig = {
|
|
enabled: false,
|
|
time: '08:00',
|
|
// Ship with the general-purpose Orbit briefing skill pre-selected so a
|
|
// fresh install runs against a real adaptive template instead of the
|
|
// bare built-in prompt. Users can clear it from Settings → Orbit to fall
|
|
// back to the built-in prompt or pick another scenario === 'orbit' skill.
|
|
templateSkillId: 'orbit-general',
|
|
};
|
|
|
|
export const DEFAULT_CONFIG: AppConfig = {
|
|
mode: 'daemon',
|
|
apiKey: '',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-sonnet-4-5',
|
|
// New configs should be explicit. loadConfig() still detects parsed legacy
|
|
// saved configs that did not have this field and migrates those from their
|
|
// saved baseUrl/model before applying the current migration version.
|
|
apiProtocol: 'anthropic',
|
|
apiVersion: '',
|
|
apiProtocolConfigs: {},
|
|
configMigrationVersion: CONFIG_MIGRATION_VERSION,
|
|
apiProviderBaseUrl: 'https://api.anthropic.com',
|
|
agentId: null,
|
|
skillId: null,
|
|
designSystemId: null,
|
|
onboardingCompleted: false,
|
|
theme: 'system',
|
|
accentColor: DEFAULT_ACCENT_COLOR,
|
|
mediaProviders: {},
|
|
composio: {},
|
|
agentModels: {},
|
|
agentCliEnv: {},
|
|
pet: DEFAULT_PET,
|
|
notifications: DEFAULT_NOTIFICATIONS,
|
|
orbit: DEFAULT_ORBIT,
|
|
projectLocations: [],
|
|
defaultProjectLocationId: 'default',
|
|
// Telemetry defaults to ON so fresh-install users emit onboarding /
|
|
// ui_click events from the first frame. The disclosure modal still
|
|
// appears after `onboardingCompleted` flips, and Settings → Privacy
|
|
// remains the one-click opt-out. Without these defaults the gate at
|
|
// `daemon/src/analytics.ts` (`if (telemetry?.metrics !== true) return`)
|
|
// dropped every event fired during onboarding because no consent
|
|
// existed yet — observed live on the nightly.10 QA run, which left
|
|
// zero `page_view pn=onboarding` rows on PostHog despite the user
|
|
// completing the flow. `artifactManifest` stays off; the existing
|
|
// PrivacySection lets the user enable it explicitly.
|
|
telemetry: { metrics: true, content: true, artifactManifest: false },
|
|
};
|
|
|
|
/** Well-known providers with pre-filled base URLs. */
|
|
export interface KnownProvider {
|
|
label: string;
|
|
protocol: ApiProtocol;
|
|
baseUrl: string;
|
|
/** Default model to apply when the provider is selected. */
|
|
model: string;
|
|
/** Optional provider-specific model choices shown in Settings. */
|
|
models?: string[];
|
|
/** Some local/self-hosted endpoints do not require bearer credentials. */
|
|
requiresApiKey?: boolean;
|
|
}
|
|
|
|
// Some providers appear more than once because they expose both
|
|
// Anthropic-compatible (/v1/messages) and OpenAI-compatible
|
|
// (/v1/chat/completions) gateways. Keep those entries separate so the Settings
|
|
// UI can scope quick-fill presets and model suggestions to the selected
|
|
// protocol.
|
|
//
|
|
// Model lists are hand-curated from provider docs/current public presets rather
|
|
// than fetched dynamically. To add a provider, include a user-facing label, the
|
|
// protocol that determines request routing, the base URL, a default model, and
|
|
// optional provider-specific model choices.
|
|
export const KNOWN_PROVIDERS: KnownProvider[] = [
|
|
{
|
|
label: 'Anthropic (Claude)',
|
|
protocol: 'anthropic',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-sonnet-4-5',
|
|
models: ['claude-sonnet-4-5', 'claude-opus-4-5', 'claude-haiku-4-5'],
|
|
},
|
|
{
|
|
label: 'DeepSeek — Anthropic',
|
|
protocol: 'anthropic',
|
|
baseUrl: 'https://api.deepseek.com/anthropic',
|
|
model: 'deepseek-chat',
|
|
models: [
|
|
'deepseek-chat',
|
|
'deepseek-reasoner',
|
|
'deepseek-v4-flash',
|
|
'deepseek-v4-pro',
|
|
],
|
|
},
|
|
{
|
|
label: 'MiniMax — Anthropic',
|
|
protocol: 'anthropic',
|
|
baseUrl: 'https://api.minimaxi.com/anthropic',
|
|
model: 'MiniMax-M2.7-highspeed',
|
|
models: [
|
|
'MiniMax-M2.7-highspeed',
|
|
'MiniMax-M2.7',
|
|
'MiniMax-M2.5-highspeed',
|
|
'MiniMax-M2.5',
|
|
'MiniMax-M2.1-highspeed',
|
|
'MiniMax-M2.1',
|
|
'MiniMax-M2',
|
|
],
|
|
},
|
|
{
|
|
label: 'OpenAI',
|
|
protocol: 'openai',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
model: 'gpt-4o',
|
|
models: ['gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini'],
|
|
},
|
|
{
|
|
label: 'Azure OpenAI',
|
|
protocol: 'azure',
|
|
baseUrl: '',
|
|
model: '',
|
|
models: [],
|
|
},
|
|
{
|
|
label: 'Google Gemini',
|
|
protocol: 'google',
|
|
baseUrl: 'https://generativelanguage.googleapis.com',
|
|
model: 'gemini-2.0-flash',
|
|
models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-pro', 'gemini-1.5-flash'],
|
|
},
|
|
{
|
|
label: 'DeepSeek — OpenAI',
|
|
protocol: 'openai',
|
|
baseUrl: 'https://api.deepseek.com',
|
|
model: 'deepseek-chat',
|
|
models: [
|
|
'deepseek-chat',
|
|
'deepseek-reasoner',
|
|
'deepseek-v4-flash',
|
|
'deepseek-v4-pro',
|
|
],
|
|
},
|
|
{
|
|
label: 'MiniMax — OpenAI',
|
|
protocol: 'openai',
|
|
baseUrl: 'https://api.minimaxi.com/v1',
|
|
model: 'MiniMax-M2.7-highspeed',
|
|
models: [
|
|
'MiniMax-M2.7-highspeed',
|
|
'MiniMax-M2.7',
|
|
'MiniMax-M2.5-highspeed',
|
|
'MiniMax-M2.5',
|
|
'MiniMax-M2.1-highspeed',
|
|
'MiniMax-M2.1',
|
|
'MiniMax-M2',
|
|
],
|
|
},
|
|
{
|
|
label: 'MiMo (Xiaomi) — OpenAI',
|
|
protocol: 'openai',
|
|
baseUrl: 'https://token-plan-cn.xiaomimimo.com/v1',
|
|
model: 'mimo-v2.5-pro',
|
|
models: ['mimo-v2.5-pro'],
|
|
},
|
|
{
|
|
label: 'Ollama Cloud (managed)',
|
|
protocol: 'ollama',
|
|
baseUrl: 'https://ollama.com',
|
|
model: 'gpt-oss:120b',
|
|
models: [
|
|
'cogito-2.1:671b',
|
|
'deepseek-v3.1:671b',
|
|
'deepseek-v3.2',
|
|
'deepseek-v4-flash',
|
|
'deepseek-v4-pro',
|
|
'devstral-2:123b',
|
|
'devstral-small-2:24b',
|
|
'gemini-3-flash-preview',
|
|
'gemma3:4b',
|
|
'gemma3:12b',
|
|
'gemma3:27b',
|
|
'gemma4:31b',
|
|
'glm-4.6',
|
|
'glm-4.7',
|
|
'glm-5',
|
|
'glm-5.1',
|
|
'gpt-oss:20b',
|
|
'gpt-oss:120b',
|
|
'kimi-k2:1t',
|
|
'kimi-k2-thinking',
|
|
'kimi-k2.5',
|
|
'kimi-k2.6',
|
|
'minimax-m2',
|
|
'minimax-m2.1',
|
|
'minimax-m2.5',
|
|
'minimax-m2.7',
|
|
'ministral-3:3b',
|
|
'ministral-3:8b',
|
|
'ministral-3:14b',
|
|
'mistral-large-3:675b',
|
|
'nemotron-3-nano:30b',
|
|
'nemotron-3-super',
|
|
'qwen3-coder:480b',
|
|
'qwen3-coder-next',
|
|
'qwen3-next:80b',
|
|
'qwen3-vl:235b',
|
|
'qwen3-vl:235b-instruct',
|
|
'qwen3.5:397b',
|
|
'rnj-1:8b',
|
|
],
|
|
},
|
|
{
|
|
label: 'Ollama Self-hosted (local)',
|
|
protocol: 'ollama',
|
|
baseUrl: 'http://localhost:11434',
|
|
model: 'gemma3:4b',
|
|
models: ['gemma3:4b', 'gemma3:12b', 'gemma3:27b', 'gpt-oss:20b'],
|
|
requiresApiKey: false,
|
|
},
|
|
{
|
|
label: 'MiMo (Xiaomi) — Anthropic',
|
|
protocol: 'anthropic',
|
|
baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',
|
|
model: 'mimo-v2.5-pro',
|
|
models: ['mimo-v2.5-pro'],
|
|
},
|
|
{
|
|
label: 'SenseAudio',
|
|
protocol: 'senseaudio',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
model: 'senseaudio-s2',
|
|
models: [
|
|
'senseaudio-s2',
|
|
'senseaudio-s2-flash',
|
|
'deepseek-v4-flash',
|
|
'deepseek-v4-pro',
|
|
'glm-5.1',
|
|
'kimi-k2.6',
|
|
'MiniMax-M2.7-highspeed',
|
|
'MiniMax-M2.7',
|
|
],
|
|
},
|
|
];
|
|
|
|
function normalizePet(input: Partial<PetConfig> | undefined): PetConfig {
|
|
if (!input) return { ...DEFAULT_PET, custom: { ...DEFAULT_PET.custom } };
|
|
// Merge stored values onto defaults so newly-added fields land safely
|
|
// when an older config is rehydrated.
|
|
return {
|
|
...DEFAULT_PET,
|
|
...input,
|
|
custom: { ...DEFAULT_PET.custom, ...(input.custom ?? {}) },
|
|
};
|
|
}
|
|
|
|
function normalizeNotifications(
|
|
input: Partial<NotificationsConfig> | undefined,
|
|
): NotificationsConfig {
|
|
return { ...DEFAULT_NOTIFICATIONS, ...(input ?? {}) };
|
|
}
|
|
|
|
function normalizeOrbit(input: Partial<OrbitConfig> | undefined): OrbitConfig {
|
|
const time = typeof input?.time === 'string' && isValidOrbitTime(input.time)
|
|
? input.time
|
|
: DEFAULT_ORBIT.time;
|
|
return { ...DEFAULT_ORBIT, ...(input ?? {}), time };
|
|
}
|
|
|
|
function isValidOrbitTime(time: string): boolean {
|
|
const match = /^(\d{2}):(\d{2})$/.exec(time);
|
|
if (!match) return false;
|
|
const hours = Number(match[1]);
|
|
const minutes = Number(match[2]);
|
|
return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59;
|
|
}
|
|
|
|
function inferApiProtocol(model: string, baseUrl: string): ApiProtocol {
|
|
try {
|
|
const normalized = (baseUrl || '').toLowerCase();
|
|
// Any config pointing at ollama.com should resolve to the new ollama
|
|
// protocol so both chat and the connection test hit the native Ollama
|
|
// proxy instead of the Anthropic or OpenAI paths.
|
|
if (normalized.includes('ollama.com')) return 'ollama';
|
|
// SenseAudio host gets routed to its own proxy so the daemon log line
|
|
// and the BYOK tab UI stay consistent with the protocol the user
|
|
// picked — even though the on-wire shape is OpenAI-compatible.
|
|
if (normalized.includes('senseaudio.cn')) return 'senseaudio';
|
|
return isOpenAICompatible(model, baseUrl) ? 'openai' : 'anthropic';
|
|
} catch {
|
|
// Preserve the rest of the user's settings even if an old saved base URL is
|
|
// malformed enough for URL parsing to throw. Anthropic is the safest default
|
|
// because it matches the original built-in provider.
|
|
return 'anthropic';
|
|
}
|
|
}
|
|
|
|
export function loadConfig(): AppConfig {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) {
|
|
return {
|
|
...DEFAULT_CONFIG,
|
|
pet: normalizePet(DEFAULT_PET),
|
|
notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS),
|
|
orbit: normalizeOrbit(DEFAULT_ORBIT),
|
|
};
|
|
}
|
|
const parsed = JSON.parse(raw) as Partial<AppConfig>;
|
|
// Strip daemon-owned privacy fields if a stale localStorage payload
|
|
// still carries them. Older builds wrote these to localStorage; we
|
|
// now treat the daemon as authoritative so the user can rotate /
|
|
// revoke without leaving residue in browser storage.
|
|
for (const key of DAEMON_OWNED_KEYS) {
|
|
delete (parsed as Record<string, unknown>)[key];
|
|
}
|
|
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(
|
|
parsed,
|
|
'apiProtocol',
|
|
);
|
|
const merged: AppConfig = {
|
|
...DEFAULT_CONFIG,
|
|
...parsed,
|
|
apiProtocolConfigs: { ...(parsed.apiProtocolConfigs ?? {}) },
|
|
mediaProviders: { ...(parsed.mediaProviders ?? {}) },
|
|
composio: { ...(parsed.composio ?? {}) },
|
|
agentModels: { ...(parsed.agentModels ?? {}) },
|
|
agentCliEnv: { ...(parsed.agentCliEnv ?? {}) },
|
|
accentColor: normalizeAccentColor(parsed.accentColor) ?? DEFAULT_CONFIG.accentColor,
|
|
pet: normalizePet(parsed.pet),
|
|
notifications: normalizeNotifications(parsed.notifications),
|
|
orbit: normalizeOrbit(parsed.orbit),
|
|
};
|
|
|
|
if (parsed.configMigrationVersion !== CONFIG_MIGRATION_VERSION) {
|
|
// Migration v1: configs saved before apiProtocol existed need an explicit
|
|
// protocol so old OpenAI-compatible endpoints keep routing correctly.
|
|
// This is version-gated instead of only field-gated so a later imported
|
|
// legacy config can be migrated when it is loaded.
|
|
if (!parsedHasApiProtocol) {
|
|
merged.apiProtocol = inferApiProtocol(merged.model, merged.baseUrl);
|
|
// Ollama Cloud legacy configs may carry a base URL that includes
|
|
// /api or /api/ — normalize to the host root so the daemon's own
|
|
// /api/chat appending doesn't double up.
|
|
if (merged.apiProtocol === 'ollama') {
|
|
merged.baseUrl = merged.baseUrl
|
|
.replace(/\/api\/?$/, '')
|
|
.replace(/\/+$/, '');
|
|
}
|
|
// Also set apiProviderBaseUrl so setApiProtocol() can correctly identify
|
|
// 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,
|
|
);
|
|
merged.apiProviderBaseUrl = knownProvider?.baseUrl ?? null;
|
|
}
|
|
merged.configMigrationVersion = CONFIG_MIGRATION_VERSION;
|
|
}
|
|
|
|
return merged;
|
|
} catch {
|
|
return {
|
|
...DEFAULT_CONFIG,
|
|
pet: normalizePet(DEFAULT_PET),
|
|
notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS),
|
|
orbit: normalizeOrbit(DEFAULT_ORBIT),
|
|
};
|
|
}
|
|
}
|
|
|
|
interface PublicComposioConfigResponse {
|
|
configured?: boolean;
|
|
apiKeyTail?: string;
|
|
}
|
|
|
|
interface PublicMediaProviderConfigEntry {
|
|
configured?: boolean;
|
|
source?: string;
|
|
apiKeyTail?: string;
|
|
baseUrl?: string;
|
|
model?: string;
|
|
}
|
|
|
|
interface PublicMediaProviderConfigResponse {
|
|
providers?: Record<string, PublicMediaProviderConfigEntry>;
|
|
}
|
|
|
|
export type DaemonMediaProvidersFetchResult =
|
|
| {
|
|
status: 'ok';
|
|
providers: AppConfig['mediaProviders'];
|
|
}
|
|
| {
|
|
status: 'error';
|
|
};
|
|
|
|
interface MediaProviderDaemonWriteEntry {
|
|
apiKey?: string;
|
|
preserveApiKey?: boolean;
|
|
baseUrl?: string;
|
|
model?: string;
|
|
}
|
|
|
|
interface MediaProviderDaemonWriteRequest {
|
|
providers: Record<string, MediaProviderDaemonWriteEntry>;
|
|
force: boolean;
|
|
}
|
|
|
|
function hasAnyDaemonManagedMediaProvider(
|
|
providers: Record<string, MediaProviderCredentials> | null | undefined,
|
|
): boolean {
|
|
if (!providers) return false;
|
|
return Object.values(providers).some((entry) => isStoredMediaProviderEntryPresent(entry));
|
|
}
|
|
|
|
function hasRecoverableLocalMediaProviderFields(
|
|
entry: MediaProviderCredentials | null | undefined,
|
|
): boolean {
|
|
return Boolean(
|
|
entry?.apiKey?.trim()
|
|
|| entry?.baseUrl?.trim()
|
|
|| entry?.model?.trim(),
|
|
);
|
|
}
|
|
|
|
function isMarkerOnlyMediaProviderEntry(
|
|
entry: MediaProviderCredentials | null | undefined,
|
|
): boolean {
|
|
return isStoredMediaProviderEntryPresent(entry)
|
|
&& !hasRecoverableLocalMediaProviderFields(entry);
|
|
}
|
|
|
|
export function isStoredMediaProviderEntryPresent(
|
|
entry: MediaProviderCredentials | null | undefined,
|
|
): boolean {
|
|
return Boolean(
|
|
entry?.apiKey?.trim()
|
|
|| entry?.baseUrl?.trim()
|
|
|| entry?.model?.trim()
|
|
|| entry?.apiKeyConfigured
|
|
|| entry?.apiKeyTail?.trim(),
|
|
);
|
|
}
|
|
|
|
export function isStoredMediaProviderEntryEmpty(
|
|
entry: MediaProviderCredentials | null | undefined,
|
|
): boolean {
|
|
return !isStoredMediaProviderEntryPresent(entry);
|
|
}
|
|
|
|
function defaultBaseUrlForProvider(providerId: string): string {
|
|
return MEDIA_PROVIDERS.find((provider) => provider.id === providerId)?.defaultBaseUrl ?? '';
|
|
}
|
|
|
|
export function buildMediaProvidersForDaemonSave(
|
|
currentProviders: Record<string, MediaProviderCredentials> | undefined,
|
|
daemonProviders: Record<string, MediaProviderCredentials> | null | undefined,
|
|
options?: { force?: boolean },
|
|
): MediaProviderDaemonWriteRequest {
|
|
const providers: Record<string, MediaProviderDaemonWriteEntry> = {};
|
|
for (const [providerId, currentEntry] of Object.entries(currentProviders ?? {})) {
|
|
const daemonEntry = daemonProviders?.[providerId];
|
|
const apiKey = currentEntry?.apiKey?.trim() ?? '';
|
|
const hasStoredKeyMarker = Boolean(
|
|
currentEntry?.apiKeyTail?.trim()
|
|
|| daemonEntry?.apiKeyTail?.trim(),
|
|
);
|
|
const preserveApiKey = !apiKey && Boolean(
|
|
currentEntry?.apiKeyConfigured
|
|
&& hasStoredKeyMarker,
|
|
);
|
|
const explicitBaseUrl =
|
|
currentEntry?.baseUrl?.trim()
|
|
|| daemonEntry?.baseUrl?.trim()
|
|
|| '';
|
|
const model = currentEntry?.model?.trim() || daemonEntry?.model?.trim() || '';
|
|
if (!apiKey && !preserveApiKey && !explicitBaseUrl && !model) continue;
|
|
const baseUrl = explicitBaseUrl || defaultBaseUrlForProvider(providerId);
|
|
providers[providerId] = {
|
|
...(apiKey ? { apiKey } : {}),
|
|
...(preserveApiKey ? { preserveApiKey: true } : {}),
|
|
...(baseUrl ? { baseUrl } : {}),
|
|
...(model ? { model } : {}),
|
|
};
|
|
}
|
|
return {
|
|
providers,
|
|
force: Boolean(options?.force),
|
|
};
|
|
}
|
|
|
|
export async function fetchComposioConfigFromDaemon(): Promise<AppConfig['composio'] | null> {
|
|
try {
|
|
const response = await fetch('/api/connectors/composio/config');
|
|
if (!response.ok) return null;
|
|
const payload = await response.json() as PublicComposioConfigResponse;
|
|
return {
|
|
apiKey: '',
|
|
apiKeyConfigured: Boolean(payload.configured),
|
|
apiKeyTail: payload.apiKeyTail ?? '',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function fetchMediaProvidersFromDaemon(): Promise<DaemonMediaProvidersFetchResult> {
|
|
try {
|
|
const response = await fetch('/api/media/config');
|
|
if (!response.ok) return { status: 'error' };
|
|
const payload = await response.json() as PublicMediaProviderConfigResponse;
|
|
const rawProviders = payload.providers ?? {};
|
|
const providers: AppConfig['mediaProviders'] = {};
|
|
for (const [providerId, entry] of Object.entries(rawProviders)) {
|
|
providers[providerId] = {
|
|
apiKey: '',
|
|
apiKeyConfigured: Boolean(entry?.configured),
|
|
apiKeyTail: entry?.apiKeyTail ?? '',
|
|
baseUrl: entry?.baseUrl ?? '',
|
|
...(typeof entry?.source === 'string' && entry.source.trim()
|
|
? { source: entry.source.trim() }
|
|
: {}),
|
|
...(typeof entry?.model === 'string' && entry.model.trim()
|
|
? { model: entry.model.trim() }
|
|
: {}),
|
|
};
|
|
}
|
|
return {
|
|
status: 'ok',
|
|
providers,
|
|
};
|
|
} catch {
|
|
return { status: 'error' };
|
|
}
|
|
}
|
|
|
|
export async function syncComposioConfigToDaemon(
|
|
config: AppConfig['composio'] | undefined,
|
|
): Promise<boolean> {
|
|
const apiKey = config?.apiKey ?? '';
|
|
const payload = {
|
|
...(apiKey.trim() || !config?.apiKeyConfigured ? { apiKey } : {}),
|
|
};
|
|
try {
|
|
const response = await fetch('/api/connectors/composio/config', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Privacy-sensitive fields the user can revoke. We deliberately keep
|
|
// these out of localStorage so the daemon remains the single source of
|
|
// truth: clearing app-config.json (or rotating via "Delete my data")
|
|
// fully resets the install identity, with no residual cohort key
|
|
// silently sitting in browser storage where the user can't see it.
|
|
const DAEMON_OWNED_KEYS = new Set<keyof AppConfig>([
|
|
'installationId',
|
|
'telemetry',
|
|
'privacyDecisionAt',
|
|
]);
|
|
|
|
const AGENT_CLI_SECRET_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'CODEX_API_KEY', 'OPENAI_API_KEY']);
|
|
|
|
function sanitizeAgentCliEnv(agentCliEnv: AppConfig['agentCliEnv']): AppConfig['agentCliEnv'] {
|
|
if (!agentCliEnv) return agentCliEnv;
|
|
const sanitized: NonNullable<AppConfig['agentCliEnv']> = {};
|
|
for (const [agentId, env] of Object.entries(agentCliEnv)) {
|
|
const safeEnv = Object.fromEntries(
|
|
Object.entries(env ?? {}).filter(([key]) => !AGENT_CLI_SECRET_ENV_KEYS.has(key)),
|
|
);
|
|
sanitized[agentId] = safeEnv;
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
export function saveConfig(config: AppConfig): void {
|
|
const sanitized: AppConfig = { ...config, agentCliEnv: sanitizeAgentCliEnv(config.agentCliEnv) };
|
|
for (const key of DAEMON_OWNED_KEYS) {
|
|
delete (sanitized as unknown as Record<string, unknown>)[key];
|
|
}
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitized));
|
|
}
|
|
|
|
export function mergeDaemonConfig(
|
|
localConfig: AppConfig,
|
|
daemonConfig: AppConfigPrefs | null,
|
|
): AppConfig {
|
|
const next = { ...localConfig };
|
|
if (!daemonConfig) return next;
|
|
|
|
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,
|
|
};
|
|
}
|
|
next.agentCliEnv = daemonConfig.agentCliEnv ?? {};
|
|
if (daemonConfig.disabledSkills !== undefined) {
|
|
next.disabledSkills = daemonConfig.disabledSkills;
|
|
}
|
|
if (daemonConfig.disabledDesignSystems !== undefined) {
|
|
next.disabledDesignSystems = daemonConfig.disabledDesignSystems;
|
|
}
|
|
if (daemonConfig.orbit !== undefined) {
|
|
next.orbit = normalizeOrbit(daemonConfig.orbit);
|
|
}
|
|
if (daemonConfig.installationId !== undefined) {
|
|
next.installationId = daemonConfig.installationId;
|
|
}
|
|
if (daemonConfig.telemetry !== undefined) {
|
|
next.telemetry = { ...daemonConfig.telemetry };
|
|
}
|
|
if (daemonConfig.privacyDecisionAt !== undefined) {
|
|
next.privacyDecisionAt = daemonConfig.privacyDecisionAt;
|
|
} else if (
|
|
daemonConfig.installationId !== undefined ||
|
|
daemonConfig.telemetry !== undefined
|
|
) {
|
|
// One-shot migration for configs created before privacyDecisionAt
|
|
// existed. If the daemon already has an id or telemetry prefs, the user
|
|
// has resolved the first-run prompt and should not see it again.
|
|
next.privacyDecisionAt = Date.now();
|
|
}
|
|
if (daemonConfig.customInstructions !== undefined) {
|
|
next.customInstructions = daemonConfig.customInstructions ?? undefined;
|
|
}
|
|
if (daemonConfig.projectLocations !== undefined) {
|
|
next.projectLocations = daemonConfig.projectLocations;
|
|
}
|
|
if (daemonConfig.defaultProjectLocationId !== undefined) {
|
|
next.defaultProjectLocationId = daemonConfig.defaultProjectLocationId ?? 'default';
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export function mergeDaemonMediaProviders(
|
|
localConfig: AppConfig,
|
|
daemonProviders: AppConfig['mediaProviders'] | null,
|
|
options?: {
|
|
preserveLocalProviderIds?: ReadonlySet<string>;
|
|
},
|
|
): AppConfig {
|
|
if (daemonProviders == null) {
|
|
return { ...localConfig };
|
|
}
|
|
|
|
if (!hasAnyDaemonManagedMediaProvider(daemonProviders)) {
|
|
return {
|
|
...localConfig,
|
|
mediaProviders: Object.fromEntries(
|
|
Object.entries(localConfig.mediaProviders ?? {}).filter(([, entry]) => !isMarkerOnlyMediaProviderEntry(entry)),
|
|
),
|
|
};
|
|
}
|
|
|
|
const mediaProviders = { ...(localConfig.mediaProviders ?? {}) };
|
|
for (const [providerId, daemonEntry] of Object.entries(daemonProviders ?? {})) {
|
|
if (!isStoredMediaProviderEntryPresent(daemonEntry)) continue;
|
|
const localEntry = mediaProviders[providerId];
|
|
const preserveLocalPendingEdit = Boolean(
|
|
options?.preserveLocalProviderIds?.has(providerId)
|
|
&& hasRecoverableLocalMediaProviderFields(localEntry),
|
|
);
|
|
mediaProviders[providerId] = preserveLocalPendingEdit
|
|
? { ...daemonEntry, ...localEntry }
|
|
: { ...daemonEntry };
|
|
}
|
|
|
|
return {
|
|
...localConfig,
|
|
mediaProviders,
|
|
};
|
|
}
|
|
|
|
export function hasAnyConfiguredProvider(
|
|
providers: Record<string, MediaProviderCredentials> | undefined,
|
|
): boolean {
|
|
if (!providers) return false;
|
|
return Object.values(providers).some((entry) => isStoredMediaProviderEntryPresent(entry));
|
|
}
|
|
|
|
export function shouldSyncLocalMediaProvidersToDaemon(
|
|
localProviders: Record<string, MediaProviderCredentials> | undefined,
|
|
daemonProviders: Record<string, MediaProviderCredentials> | null | undefined,
|
|
): boolean {
|
|
return daemonProviders != null
|
|
&& Object.values(localProviders ?? {}).some((entry) => hasRecoverableLocalMediaProviderFields(entry))
|
|
&& !hasAnyDaemonManagedMediaProvider(daemonProviders);
|
|
}
|
|
|
|
export async function syncMediaProvidersToDaemon(
|
|
providers: Record<string, MediaProviderCredentials> | undefined,
|
|
options?: {
|
|
force?: boolean;
|
|
daemonProviders?: Record<string, MediaProviderCredentials> | null;
|
|
throwOnError?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (!providers) return;
|
|
try {
|
|
const payload = buildMediaProvidersForDaemonSave(
|
|
providers,
|
|
options?.daemonProviders,
|
|
{ force: options?.force },
|
|
);
|
|
const response = await fetch('/api/media/config', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!response.ok) throw new Error(`Failed to sync media config (${response.status})`);
|
|
} catch {
|
|
if (options?.throwOnError) throw new Error('Media config save failed');
|
|
// 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,
|
|
options?: { throwOnError?: boolean },
|
|
): Promise<void> {
|
|
const prefs: AppConfigPrefs = {
|
|
onboardingCompleted: config.onboardingCompleted,
|
|
agentId: config.agentId,
|
|
agentModels: config.agentModels,
|
|
agentCliEnv: config.agentCliEnv,
|
|
skillId: config.skillId,
|
|
designSystemId: config.designSystemId,
|
|
disabledSkills: config.disabledSkills,
|
|
disabledDesignSystems: config.disabledDesignSystems,
|
|
orbit: normalizeOrbit(config.orbit),
|
|
installationId: config.installationId,
|
|
telemetry: config.telemetry,
|
|
privacyDecisionAt: config.privacyDecisionAt,
|
|
customInstructions: config.customInstructions ?? null,
|
|
projectLocations: config.projectLocations ?? [],
|
|
defaultProjectLocationId: config.defaultProjectLocationId ?? 'default',
|
|
};
|
|
try {
|
|
const response = await fetch('/api/app-config', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(prefs),
|
|
});
|
|
if (!response.ok) throw new Error(`Failed to sync app config (${response.status})`);
|
|
} catch (error) {
|
|
if (options?.throwOnError) throw error;
|
|
// Daemon offline; localStorage keeps the user's copy for the next save.
|
|
}
|
|
}
|