Files
boocode/apps/web/src/api/client.ts
indifferentketchup ac239ac043 project-ux: archive/rename/Open-in-Gitea sidebar context menu, archived projects landing, create-project bootstrap with Gitea remote
Server:
- projects.status + projects.gitea_remote (additive) with CHECK ('open','archived')
- GET /api/projects?status=archived; PATCH /api/projects/:id (rename);
  POST /api/projects/:id/archive | unarchive; POST /api/projects/create
- POST /api/projects ON CONFLICT (path) DO UPDATE SET status='open': re-add
  of archived path restores existing row (preserves id + FKs); already-open
  path returns 409. Detected-repos picker now excludes only status='open'.
- New gitea.ts (createGiteaRepo + GiteaRepoExistsError) and
  project_bootstrap.ts (sanitize name, mkdir under PROJECT_ROOT_WHITELIST,
  git init -b main + first commit with -c user.name/email per-command, optional
  Gitea repo create + remote add + push; all via execFile, no shell).
- 3 new user-stream frames: project_archived, project_unarchived, project_updated.
- sidebar.ts now selects path + gitea_remote and filters status='open'.
- Gitea env added to config.ts (GITEA_BASE_URL, GITEA_USER, GITEA_TOKEN,
  GITEA_SSH_HOST).
- docker-compose.yml /opt mount flipped to rw so create-project can mkdir.
- auto_name.ts gate relaxed from `!== 1` to `< 1` (fires on every turn while
  chat name is empty, not only the first).

Web:
- ProjectSidebar: project rows use proper Radix ContextMenu; items Rename /
  Archive / Open in Gitea. Inline rename, archive confirm dialog.
  Removed obsolete handleRemove + DropdownMenu hack.
- Home: Add-existing + Create-new buttons; collapsible Archived Projects
  section with Restore.
- New CreateProjectModal: name + live folder preview, commit msg, Private/
  Public radio, create-Gitea-remote checkbox, toast on success/warnings.
- New projectUrls.ts giteaUrlFor() — uses gitea_remote when present,
  falls back to convention URL.
- 3 new event types in sessionEvents.ts with idempotent useSidebar handlers.
- SidebarProject extended with path + gitea_remote so Open-in-Gitea can
  resolve without a separate fetch.
2026-05-16 02:51:59 +00:00

180 lines
5.8 KiB
TypeScript

import type {
Project,
AvailableProject,
Session,
Chat,
Message,
ModelInfo,
SidebarResponse,
ListDirResult,
ViewFileResult,
} from './types';
export class ApiError extends Error {
constructor(
public status: number,
public body: unknown
) {
super(typeof body === 'object' && body && 'error' in body ? String((body as { error: unknown }).error) : `HTTP ${status}`);
}
}
async function request<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const res = await fetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? JSON.parse(text) : undefined;
if (!res.ok) throw new ApiError(res.status, data);
return data as T;
}
export const api = {
health: () => request<{ status: string; db: boolean }>('/api/health'),
projects: {
list: (params?: { status?: 'open' | 'archived' }) =>
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
available: () => request<AvailableProject[]>('/api/projects/available'),
add: (body: { path: string; name?: string }) =>
request<Project>('/api/projects', {
method: 'POST',
body: JSON.stringify(body),
}),
update: (id: string, body: { name: string }) =>
request<Project>(`/api/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
archive: (id: string) =>
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
create: (body: {
name: string;
commit_message?: string;
visibility?: 'private' | 'public';
create_gitea_remote?: boolean;
}) =>
request<{
project: Project;
bootstrap: {
folder_created: boolean;
git_initialized: boolean;
first_commit: boolean;
gitea_remote_created: boolean;
gitea_pushed: boolean;
warnings: string[];
};
}>(`/api/projects/create`, {
method: 'POST',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
listDir: (id: string, path: string) =>
request<ListDirResult>(`/api/projects/${id}/list_dir?path=${encodeURIComponent(path)}`),
viewFile: (id: string, path: string) =>
request<ViewFileResult>(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`),
files: (id: string) =>
request<{ files: string[] }>(`/api/projects/${id}/files`),
},
sessions: {
listForProject: (projectId: string, status?: 'open' | 'archived') =>
request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
create: (
projectId: string,
body: { name?: string; model?: string; system_prompt?: string }
) =>
request<Session>(`/api/projects/${projectId}/sessions`, {
method: 'POST',
body: JSON.stringify(body),
}),
get: (id: string) => request<Session>(`/api/sessions/${id}`),
update: (
id: string,
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt'>>
) =>
request<Session>(`/api/sessions/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
archive: (id: string) =>
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
},
chats: {
listForSession: (sessionId: string) =>
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
create: (sessionId: string, body?: { name?: string }) =>
request<Chat>(`/api/sessions/${sessionId}/chats`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
update: (chatId: string, body: { name?: string; status?: 'open' | 'closed' }) =>
request<Chat>(`/api/chats/${chatId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (chatId: string) =>
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
messages: (chatId: string) =>
request<Message[]>(`/api/chats/${chatId}/messages`),
compact: (chatId: string) =>
request<{ compact_message_id: string }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
stop: (chatId: string) =>
request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
forceSend: (chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) }
),
},
messages: {
list: (sessionId: string) =>
request<Message[]>(`/api/sessions/${sessionId}/messages`),
send: (chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/messages`,
{
method: 'POST',
body: JSON.stringify({ content }),
}
),
regenerate: (chatId: string, messageId: string) =>
request<{ assistant_message_id: string }>(
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
{ method: 'POST' }
),
},
models: () => request<ModelInfo[]>('/api/models'),
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>
request<Record<string, unknown>>('/api/settings', {
method: 'PATCH',
body: JSON.stringify(body),
}),
},
sidebar: {
get: () => request<SidebarResponse>('/api/sidebar'),
},
};