first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run

This commit is contained in:
Zakaria
2026-05-04 14:58:14 -04:00
commit a46764fb1b
1210 changed files with 233231 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG, loadConfig } from './config';
import type { AppConfig } from '../types';
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => {
store.clear();
}),
});
afterEach(() => {
store.clear();
});
describe('loadConfig', () => {
it('migrates legacy OpenAI-compatible API configs to an explicit apiProtocol', () => {
const legacyConfig: Partial<AppConfig> = {
mode: 'api',
apiKey: 'sk-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
agentId: null,
skillId: null,
designSystemId: null,
};
store.set('open-design:config', JSON.stringify(legacyConfig));
const config = loadConfig();
expect(config.mode).toBe('api');
expect(config.baseUrl).toBe('https://api.deepseek.com');
expect(config.model).toBe('deepseek-chat');
expect(config.apiProtocol).toBe('openai');
expect(config.configMigrationVersion).toBe(1);
});
it('migrates legacy Anthropic API configs to an explicit apiProtocol', () => {
const legacyConfig: Partial<AppConfig> = {
mode: 'api',
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: null,
skillId: null,
designSystemId: null,
};
store.set('open-design:config', JSON.stringify(legacyConfig));
const config = loadConfig();
expect(config.apiProtocol).toBe('anthropic');
});
it('infers protocol for legacy daemon-mode API fields without changing mode', () => {
const daemonConfig: Partial<AppConfig> = {
mode: 'daemon',
apiKey: 'sk-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
agentId: 'codex',
skillId: null,
designSystemId: null,
};
store.set('open-design:config', JSON.stringify(daemonConfig));
const config = loadConfig();
expect(config.mode).toBe('daemon');
expect(config.apiProtocol).toBe('openai');
expect(config.configMigrationVersion).toBe(1);
});
it('does not overwrite an already explicit apiProtocol', () => {
const explicitConfig: Partial<AppConfig> = {
mode: 'api',
apiProtocol: 'anthropic',
apiKey: 'sk-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
agentId: null,
skillId: null,
designSystemId: null,
};
store.set('open-design:config', JSON.stringify(explicitConfig));
const config = loadConfig();
expect(config.apiProtocol).toBe('anthropic');
});
it('preserves saved settings when migration sees a malformed base URL', () => {
const legacyConfig: Partial<AppConfig> = {
mode: 'api',
apiKey: 'sk-test',
baseUrl: 'https://[broken-ipv6',
model: 'custom-model',
agentId: null,
skillId: null,
designSystemId: null,
};
store.set('open-design:config', JSON.stringify(legacyConfig));
const config = loadConfig();
expect(config.mode).toBe('api');
expect(config.apiKey).toBe('sk-test');
expect(config.baseUrl).toBe('https://[broken-ipv6');
expect(config.model).toBe('custom-model');
expect(config.apiProtocol).toBe('anthropic');
});
it('returns defaults for malformed localStorage JSON', () => {
store.set('open-design:config', '{broken-json');
expect(loadConfig()).toEqual(DEFAULT_CONFIG);
});
it('sets an explicit apiProtocol for new default configs', () => {
expect(DEFAULT_CONFIG.apiProtocol).toBe('anthropic');
expect(DEFAULT_CONFIG.configMigrationVersion).toBe(1);
});
});
+326
View File
@@ -0,0 +1,326 @@
import type { AppConfigPrefs } from '@open-design/contracts';
import { isOpenAICompatible } from '../providers/openai-compatible';
import type {
ApiProtocol,
AppConfig,
MediaProviderCredentials,
NotificationsConfig,
PetConfig,
} from '../types';
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_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',
mediaProviders: {},
agentModels: {},
pet: DEFAULT_PET,
notifications: DEFAULT_NOTIFICATIONS,
};
/** 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 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: 'MiMo (Xiaomi) — Anthropic',
protocol: 'anthropic',
baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',
model: 'mimo-v2.5-pro',
models: ['mimo-v2.5-pro'],
},
];
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 inferApiProtocol(model: string, baseUrl: string): ApiProtocol {
try {
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),
};
}
const parsed = JSON.parse(raw) as Partial<AppConfig>;
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(
parsed,
'apiProtocol',
);
const merged: AppConfig = {
...DEFAULT_CONFIG,
...parsed,
apiProtocolConfigs: { ...(parsed.apiProtocolConfigs ?? {}) },
mediaProviders: { ...(parsed.mediaProviders ?? {}) },
agentModels: { ...(parsed.agentModels ?? {}) },
pet: normalizePet(parsed.pet),
notifications: normalizeNotifications(parsed.notifications),
};
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);
// 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),
};
}
}
export function saveConfig(config: AppConfig): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
}
export function hasAnyConfiguredProvider(
providers: Record<string, MediaProviderCredentials> | undefined,
): boolean {
if (!providers) return false;
return Object.values(providers).some((entry) =>
Boolean(entry?.apiKey?.trim() || entry?.baseUrl?.trim()),
);
}
export async function syncMediaProvidersToDaemon(
providers: Record<string, MediaProviderCredentials> | undefined,
options?: { force?: boolean },
): Promise<void> {
if (!providers) return;
try {
await fetch('/api/media/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ providers, force: Boolean(options?.force) }),
});
} catch {
// 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.
}
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import litellmData from './litellm-models.json';
import {
effectiveMaxTokens,
FALLBACK_MAX_TOKENS,
MAX_MAX_TOKENS,
MIN_MAX_TOKENS,
modelMaxTokensDefault,
} from './maxTokens';
describe('modelMaxTokensDefault', () => {
it('falls through to LiteLLM data for canonical Anthropic ids', () => {
// 64k for the 4.5 line is the upstream value; this guards against the
// sync script silently dropping or rewriting these entries.
expect(modelMaxTokensDefault('claude-sonnet-4-5')).toBe(64000);
expect(modelMaxTokensDefault('claude-opus-4-5')).toBe(64000);
expect(modelMaxTokensDefault('claude-haiku-4-5')).toBe(64000);
});
it('lets OVERRIDES win over LiteLLM data', () => {
// mimo-v2.5-pro is not in LiteLLM, so this asserts the OVERRIDES path
// (not the LiteLLM path) supplied the answer.
expect((litellmData.models as Record<string, number>)['mimo-v2.5-pro']).toBeUndefined();
expect(modelMaxTokensDefault('mimo-v2.5-pro')).toBe(32768);
});
it('returns FALLBACK_MAX_TOKENS for unknown ids', () => {
expect(modelMaxTokensDefault('definitely-not-a-real-model-x9z')).toBe(FALLBACK_MAX_TOKENS);
expect(FALLBACK_MAX_TOKENS).toBe(8192);
});
});
describe('effectiveMaxTokens', () => {
it('honors an explicit user override over the model default', () => {
expect(effectiveMaxTokens({ maxTokens: 12345, model: 'claude-sonnet-4-5' })).toBe(12345);
});
it('uses the model default when no override is set', () => {
expect(effectiveMaxTokens({ model: 'mimo-v2.5-pro' })).toBe(32768);
expect(effectiveMaxTokens({ model: 'claude-sonnet-4-5' })).toBe(64000);
});
it('falls back to FALLBACK_MAX_TOKENS for unknown models with no override', () => {
expect(effectiveMaxTokens({ model: 'unknown-model' })).toBe(FALLBACK_MAX_TOKENS);
});
});
describe('effectiveMaxTokens override validation', () => {
// Stale localStorage, hand-edited config, or future schema drift can put
// anything in cfg.maxTokens. The Settings UI advertises a [1024, 200000]
// integer-stepped range, and the daemon proxy already clamps `> 0`, so
// we tighten this entry point to match the advertised contract.
it('rejects negative overrides and falls back to the model default', () => {
expect(effectiveMaxTokens({ maxTokens: -5, model: 'claude-sonnet-4-5' })).toBe(64000);
});
it('rejects zero', () => {
expect(effectiveMaxTokens({ maxTokens: 0, model: 'claude-sonnet-4-5' })).toBe(64000);
});
it('rejects overrides below MIN_MAX_TOKENS', () => {
expect(effectiveMaxTokens({ maxTokens: MIN_MAX_TOKENS - 1, model: 'claude-sonnet-4-5' })).toBe(64000);
});
it('rejects overrides above MAX_MAX_TOKENS', () => {
expect(effectiveMaxTokens({ maxTokens: MAX_MAX_TOKENS + 1, model: 'claude-sonnet-4-5' })).toBe(64000);
expect(effectiveMaxTokens({ maxTokens: 999_999_999, model: 'claude-sonnet-4-5' })).toBe(64000);
});
it('rejects non-integer overrides', () => {
expect(effectiveMaxTokens({ maxTokens: 123.9, model: 'claude-sonnet-4-5' })).toBe(64000);
expect(effectiveMaxTokens({ maxTokens: Number.NaN, model: 'claude-sonnet-4-5' })).toBe(64000);
expect(effectiveMaxTokens({ maxTokens: Number.POSITIVE_INFINITY, model: 'claude-sonnet-4-5' })).toBe(64000);
});
it('accepts the boundary values exactly', () => {
expect(effectiveMaxTokens({ maxTokens: MIN_MAX_TOKENS, model: 'claude-sonnet-4-5' })).toBe(MIN_MAX_TOKENS);
expect(effectiveMaxTokens({ maxTokens: MAX_MAX_TOKENS, model: 'claude-sonnet-4-5' })).toBe(MAX_MAX_TOKENS);
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { AppConfig } from '../types';
import litellmData from './litellm-models.json';
// Per-model output cap, used to default `max_tokens` so users on supported
// models don't have to find Settings to avoid mid-stream truncation.
//
// Source of truth: vendored slice of BerriAI/litellm's
// model_prices_and_context_window.json (MIT). Regenerate with:
// node --experimental-strip-types scripts/sync-litellm-models.ts
//
// Anything LiteLLM doesn't track (or where its value is wrong for our
// usage) goes in OVERRIDES; unknown models fall through to FALLBACK.
export const FALLBACK_MAX_TOKENS = 8192;
// Bounds the user can express via the Settings override. Source of truth
// for both the UI input attributes and runtime validation in
// `effectiveMaxTokens`, so a stale or hand-edited localStorage value
// can't sneak past the UI's promise.
export const MIN_MAX_TOKENS = 1024;
export const MAX_MAX_TOKENS = 200000;
const LITELLM_MODELS = litellmData.models as Record<string, number>;
const OVERRIDES: Record<string, number> = {
// LiteLLM lists MiMo via OpenRouter and Novita aliases (16k / 32k) but
// not the canonical `mimo-v2.5-pro` id we hand to Xiaomi's direct API.
// 32k matches what issue #29 reports as the working ceiling.
'mimo-v2.5-pro': 32768,
};
export function modelMaxTokensDefault(model: string): number {
return OVERRIDES[model] ?? LITELLM_MODELS[model] ?? FALLBACK_MAX_TOKENS;
}
function isValidOverride(value: number | undefined): value is number {
return (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= MIN_MAX_TOKENS &&
value <= MAX_MAX_TOKENS
);
}
export function effectiveMaxTokens(cfg: Pick<AppConfig, 'maxTokens' | 'model'>): number {
// Out-of-range or non-integer overrides (stale localStorage, hand-edited
// config, future schema drift) fall back to the model default rather
// than silently shipping an invalid `max_tokens` upstream.
if (isValidOverride(cfg.maxTokens)) return cfg.maxTokens;
return modelMaxTokensDefault(cfg.model);
}
+303
View File
@@ -0,0 +1,303 @@
// Project / conversation / message / tab persistence — backed by the
// daemon's SQLite store. All writes round-trip through HTTP so projects
// stay coherent across multiple browser tabs and across restarts.
//
// These helpers fail soft (returning null / [] on transport errors) so
// the UI can stay rendered when the daemon is briefly unreachable.
import type {
ChatMessage,
Conversation,
OpenTabsState,
Project,
ProjectMetadata,
ProjectTemplate,
} from '../types';
export async function listProjects(): Promise<Project[]> {
try {
const resp = await fetch('/api/projects');
if (!resp.ok) return [];
const json = (await resp.json()) as { projects: Project[] };
return json.projects ?? [];
} catch {
return [];
}
}
export async function getProject(id: string): Promise<Project | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`);
if (!resp.ok) return null;
const json = (await resp.json()) as { project: Project };
return json.project;
} catch {
return null;
}
}
export async function createProject(input: {
name: string;
skillId: string | null;
designSystemId: string | null;
pendingPrompt?: string;
metadata?: ProjectMetadata;
}): Promise<{ project: Project; conversationId: string } | null> {
try {
const id = crypto.randomUUID();
const resp = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...input }),
});
if (!resp.ok) return null;
return (await resp.json()) as { project: Project; conversationId: string };
} catch {
return null;
}
}
export async function importClaudeDesignZip(
file: File,
): Promise<{ project: Project; conversationId: string; entryFile: string } | null> {
try {
const form = new FormData();
form.append('file', file);
const resp = await fetch('/api/import/claude-design', {
method: 'POST',
body: form,
});
if (!resp.ok) return null;
return (await resp.json()) as {
project: Project;
conversationId: string;
entryFile: string;
};
} catch {
return null;
}
}
// ---------- templates ----------
export async function listTemplates(): Promise<ProjectTemplate[]> {
try {
const resp = await fetch('/api/templates');
if (!resp.ok) return [];
const json = (await resp.json()) as { templates: ProjectTemplate[] };
return json.templates ?? [];
} catch {
return [];
}
}
export async function getTemplate(id: string): Promise<ProjectTemplate | null> {
try {
const resp = await fetch(`/api/templates/${encodeURIComponent(id)}`);
if (!resp.ok) return null;
const json = (await resp.json()) as { template: ProjectTemplate };
return json.template;
} catch {
return null;
}
}
export async function saveTemplate(input: {
name: string;
description?: string;
sourceProjectId: string;
}): Promise<ProjectTemplate | null> {
try {
const resp = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!resp.ok) return null;
const json = (await resp.json()) as { template: ProjectTemplate };
return json.template;
} catch {
return null;
}
}
export async function deleteTemplate(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/templates/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
return resp.ok;
} catch {
return false;
}
}
export async function patchProject(
id: string,
patch: Partial<Project>,
): Promise<Project | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!resp.ok) return null;
const json = (await resp.json()) as { project: Project };
return json.project;
} catch {
return null;
}
}
export async function deleteProject(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
return resp.ok;
} catch {
return false;
}
}
// ---------- conversations ----------
export async function listConversations(
projectId: string,
): Promise<Conversation[]> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations`,
);
if (!resp.ok) return [];
const json = (await resp.json()) as { conversations: Conversation[] };
return json.conversations ?? [];
} catch {
return [];
}
}
export async function createConversation(
projectId: string,
title?: string,
): Promise<Conversation | null> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
},
);
if (!resp.ok) return null;
const json = (await resp.json()) as { conversation: Conversation };
return json.conversation;
} catch {
return null;
}
}
export async function patchConversation(
projectId: string,
conversationId: string,
patch: Partial<Conversation>,
): Promise<Conversation | null> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
},
);
if (!resp.ok) return null;
const json = (await resp.json()) as { conversation: Conversation };
return json.conversation;
} catch {
return null;
}
}
export async function deleteConversation(
projectId: string,
conversationId: string,
): Promise<boolean> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}`,
{ method: 'DELETE' },
);
return resp.ok;
} catch {
return false;
}
}
// ---------- messages ----------
export async function listMessages(
projectId: string,
conversationId: string,
): Promise<ChatMessage[]> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages`,
);
if (!resp.ok) return [];
const json = (await resp.json()) as { messages: ChatMessage[] };
return json.messages ?? [];
} catch {
return [];
}
}
export async function saveMessage(
projectId: string,
conversationId: string,
message: ChatMessage,
): Promise<void> {
try {
await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(message.id)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message),
},
);
} catch {
// best-effort persistence — UI keeps the message in-memory either way
}
}
// ---------- tabs ----------
export async function loadTabs(projectId: string): Promise<OpenTabsState> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/tabs`,
);
if (!resp.ok) return { tabs: [], active: null };
return (await resp.json()) as OpenTabsState;
} catch {
return { tabs: [], active: null };
}
}
export async function saveTabs(
projectId: string,
state: OpenTabsState,
): Promise<void> {
try {
await fetch(`/api/projects/${encodeURIComponent(projectId)}/tabs`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
});
} catch {
// best-effort
}
}