Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { get } from "./api";
|
||||
import type {
|
||||
McpServerStatus,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ServerEvent,
|
||||
Session,
|
||||
} from "./types";
|
||||
|
||||
/** The assistant message currently being streamed. */
|
||||
export interface Draft {
|
||||
id: string;
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
export interface SessionError {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
connected: boolean;
|
||||
sessions: Session[];
|
||||
/** Includes subagent sessions loaded on demand. */
|
||||
sessionById: Record<string, Session>;
|
||||
messages: Record<string, Message[]>;
|
||||
drafts: Record<string, Draft | undefined>;
|
||||
busy: Record<string, boolean>;
|
||||
progress: Record<string, string>;
|
||||
permissions: PermissionRequest[];
|
||||
questions: QuestionRequest[];
|
||||
errors: Record<string, SessionError[]>;
|
||||
mcp: Record<string, McpServerStatus>;
|
||||
}
|
||||
|
||||
let state: State = {
|
||||
connected: false,
|
||||
sessions: [],
|
||||
sessionById: {},
|
||||
messages: {},
|
||||
drafts: {},
|
||||
busy: {},
|
||||
progress: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
errors: {},
|
||||
mcp: {},
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function setState(fn: (s: State) => Partial<State>) {
|
||||
state = { ...state, ...fn(state) };
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useStore<T>(selector: (s: State) => T): T {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => selector(state),
|
||||
);
|
||||
}
|
||||
|
||||
function upsertSession(s: State, session: Session): Partial<State> {
|
||||
const sessionById = { ...s.sessionById, [session.id]: session };
|
||||
if (session.parent_id) return { sessionById };
|
||||
const others = s.sessions.filter((x) => x.id !== session.id);
|
||||
const sessions = [session, ...others].sort((a, b) => b.updated_at - a.updated_at);
|
||||
return { sessions, sessionById };
|
||||
}
|
||||
|
||||
let errorSeq = 0;
|
||||
|
||||
function draftFor(s: State, sid: string, mid: string): Draft {
|
||||
const d = s.drafts[sid];
|
||||
return d && d.id === mid ? { ...d, parts: [...d.parts] } : { id: mid, parts: [] };
|
||||
}
|
||||
|
||||
export function applyEvent(ev: ServerEvent) {
|
||||
switch (ev.type) {
|
||||
case "session_updated":
|
||||
setState((s) => upsertSession(s, ev.session));
|
||||
break;
|
||||
case "session_deleted":
|
||||
setState((s) => {
|
||||
const { [ev.session_id]: _, ...rest } = s.sessionById;
|
||||
return { sessions: s.sessions.filter((x) => x.id !== ev.session_id), sessionById: rest };
|
||||
});
|
||||
break;
|
||||
case "session_status":
|
||||
setState((s) => ({
|
||||
busy: { ...s.busy, [ev.session_id]: ev.busy },
|
||||
drafts: ev.busy ? s.drafts : { ...s.drafts, [ev.session_id]: undefined },
|
||||
}));
|
||||
break;
|
||||
case "message_added":
|
||||
setState((s) => {
|
||||
const list = s.messages[ev.session_id];
|
||||
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
|
||||
if (!list) return { drafts };
|
||||
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
||||
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
||||
});
|
||||
break;
|
||||
case "text_delta":
|
||||
case "reasoning_delta":
|
||||
setState((s) => {
|
||||
const d = draftFor(s, ev.session_id, ev.message_id);
|
||||
const kind = ev.type === "text_delta" ? "text" : "reasoning";
|
||||
const last = d.parts[d.parts.length - 1];
|
||||
if (last && last.type === kind) {
|
||||
d.parts[d.parts.length - 1] = { ...last, text: last.text + ev.text } as Part;
|
||||
} else {
|
||||
d.parts.push({ type: kind, text: ev.text } as Part);
|
||||
}
|
||||
return { drafts: { ...s.drafts, [ev.session_id]: d } };
|
||||
});
|
||||
break;
|
||||
case "tool_started":
|
||||
setState((s) => {
|
||||
const d = draftFor(s, ev.session_id, ev.message_id);
|
||||
d.parts.push({ type: "tool_call", id: ev.call_id, name: ev.name, input: ev.input });
|
||||
return { drafts: { ...s.drafts, [ev.session_id]: d } };
|
||||
});
|
||||
break;
|
||||
case "tool_progress":
|
||||
setState((s) => ({ progress: { ...s.progress, [ev.call_id]: ev.output } }));
|
||||
break;
|
||||
case "permission_request":
|
||||
setState((s) => ({ permissions: [...s.permissions.filter((p) => p.id !== ev.id), ev] }));
|
||||
break;
|
||||
case "permission_resolved":
|
||||
setState((s) => ({ permissions: s.permissions.filter((p) => p.id !== ev.id) }));
|
||||
break;
|
||||
case "question_request":
|
||||
setState((s) => ({ questions: [...s.questions.filter((q) => q.id !== ev.id), ev] }));
|
||||
notify(ev.questions[0]?.question ?? "");
|
||||
break;
|
||||
case "question_resolved":
|
||||
setState((s) => ({ questions: s.questions.filter((q) => q.id !== ev.id) }));
|
||||
break;
|
||||
case "error":
|
||||
setState((s) => ({
|
||||
errors: {
|
||||
...s.errors,
|
||||
[ev.session_id]: [...(s.errors[ev.session_id] ?? []), { id: ++errorSeq, message: ev.message }].slice(-5),
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "mcp_status":
|
||||
setState(() => ({ mcp: ev.servers }));
|
||||
break;
|
||||
case "resync":
|
||||
void resync();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Notify when the app is in the background and an agent needs input. */
|
||||
function notify(body: string) {
|
||||
if (document.visibilityState === "visible" || !("Notification" in window)) return;
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification("CodeCraft", { body, icon: "/icon-192.png" });
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissError(sessionId: string, id: number) {
|
||||
setState((s) => ({ errors: { ...s.errors, [sessionId]: (s.errors[sessionId] ?? []).filter((e) => e.id !== id) } }));
|
||||
}
|
||||
|
||||
export async function loadSessions() {
|
||||
const list = await get<Session[]>("/sessions");
|
||||
setState((s) => ({
|
||||
sessions: list,
|
||||
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
|
||||
busy: { ...s.busy, ...Object.fromEntries(list.map((x) => [x.id, !!x.busy])) },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadSession(id: string) {
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean }>(`/sessions/${id}`);
|
||||
setState((s) => ({
|
||||
...upsertSession(s, data.session),
|
||||
sessionById: { ...s.sessionById, [id]: data.session },
|
||||
messages: { ...s.messages, [id]: data.messages },
|
||||
busy: { ...s.busy, [id]: data.busy },
|
||||
}));
|
||||
}
|
||||
|
||||
/** After reconnects: reload everything that could have changed. */
|
||||
export async function resync() {
|
||||
const [pending, mcp] = await Promise.all([get<any[]>("/pending"), get("/mcp")]);
|
||||
setState(() => ({
|
||||
permissions: pending.filter((e) => e.type === "permission_request"),
|
||||
questions: pending.filter((e) => e.type === "question_request"),
|
||||
mcp,
|
||||
}));
|
||||
await loadSessions();
|
||||
const loaded = Object.keys(getState().messages);
|
||||
await Promise.all(loaded.map((id) => loadSession(id).catch(() => undefined)));
|
||||
}
|
||||
Reference in new issue
Block a user