Merge remote-tracking branch 'origin/main' into forrestchang/import-skill
This commit is contained in:
commit
eeb4fee1b6
148 changed files with 3040 additions and 6528 deletions
408
apps/web/shared/api/client.ts
Normal file
408
apps/web/shared/api/client.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
import type {
|
||||
Issue,
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
UpdateMeRequest,
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
ListIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
AgentRuntime,
|
||||
DaemonPairingSession,
|
||||
ApproveDaemonPairingSessionRequest,
|
||||
InboxItem,
|
||||
Comment,
|
||||
Workspace,
|
||||
MemberWithUser,
|
||||
User,
|
||||
Skill,
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
SetAgentSkillsRequest,
|
||||
PersonalAccessToken,
|
||||
CreatePersonalAccessTokenRequest,
|
||||
CreatePersonalAccessTokenResponse,
|
||||
} from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(baseUrl: string, options?: { logger?: Logger }) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
setWorkspaceId(id: string | null) {
|
||||
this.workspaceId = id;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = crypto.randomUUID().slice(0, 8);
|
||||
const start = Date.now();
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": rid,
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
if (this.workspaceId) {
|
||||
headers["X-Workspace-ID"] = this.workspaceId;
|
||||
}
|
||||
|
||||
this.logger.info(`→ ${method} ${path}`, { rid });
|
||||
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && typeof window !== "undefined") {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
this.token = null;
|
||||
this.workspaceId = null;
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
let message = `API error: ${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
if (typeof data.error === "string" && data.error) {
|
||||
message = data.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON error bodies.
|
||||
}
|
||||
this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Auth
|
||||
async sendCode(email: string): Promise<void> {
|
||||
await this.fetch("/auth/send-code", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyCode(email: string, code: string): Promise<LoginResponse> {
|
||||
return this.fetch("/auth/verify-code", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
|
||||
async updateMe(data: UpdateMeRequest): Promise<User> {
|
||||
return this.fetch("/api/me", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Issues
|
||||
async listIssues(params?: ListIssuesParams): Promise<ListIssuesResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", String(params.limit));
|
||||
if (params?.offset) search.set("offset", String(params.offset));
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.priority) search.set("priority", params.priority);
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
|
||||
async getIssue(id: string): Promise<Issue> {
|
||||
return this.fetch(`/api/issues/${id}`);
|
||||
}
|
||||
|
||||
async createIssue(data: CreateIssueRequest): Promise<Issue> {
|
||||
const search = new URLSearchParams();
|
||||
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
|
||||
return this.fetch(`/api/issues?${search}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
|
||||
return this.fetch(`/api/issues/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteIssue(id: string): Promise<void> {
|
||||
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Comments
|
||||
async listComments(issueId: string): Promise<Comment[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/comments`);
|
||||
}
|
||||
|
||||
async createComment(issueId: string, content: string, type?: string): Promise<Comment> {
|
||||
return this.fetch(`/api/issues/${issueId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content, type: type ?? "comment" }),
|
||||
});
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(commentId: string): Promise<void> {
|
||||
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Agents
|
||||
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
return this.fetch(`/api/agents?${search}`);
|
||||
}
|
||||
|
||||
async getAgent(id: string): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}`);
|
||||
}
|
||||
|
||||
async createAgent(data: CreateAgentRequest): Promise<Agent> {
|
||||
return this.fetch("/api/agents", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<void> {
|
||||
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
return this.fetch(`/api/runtimes?${search}`);
|
||||
}
|
||||
|
||||
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
|
||||
async getDaemonPairingSession(token: string): Promise<DaemonPairingSession> {
|
||||
return this.fetch(`/api/daemon/pairing-sessions/${token}`);
|
||||
}
|
||||
|
||||
async approveDaemonPairingSession(
|
||||
token: string,
|
||||
data: ApproveDaemonPairingSessionRequest,
|
||||
): Promise<DaemonPairingSession> {
|
||||
return this.fetch(`/api/daemon/pairing-sessions/${token}/approve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Inbox
|
||||
async listInbox(): Promise<InboxItem[]> {
|
||||
return this.fetch("/api/inbox");
|
||||
}
|
||||
|
||||
async markInboxRead(id: string): Promise<InboxItem> {
|
||||
return this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
async archiveInbox(id: string): Promise<InboxItem> {
|
||||
return this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
|
||||
}
|
||||
|
||||
async getUnreadInboxCount(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/unread-count");
|
||||
}
|
||||
|
||||
async markAllInboxRead(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
|
||||
}
|
||||
|
||||
async archiveAllInbox(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/archive-all", { method: "POST" });
|
||||
}
|
||||
|
||||
async archiveAllReadInbox(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
|
||||
}
|
||||
|
||||
async archiveCompletedInbox(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
||||
}
|
||||
|
||||
// Workspaces
|
||||
async listWorkspaces(): Promise<Workspace[]> {
|
||||
return this.fetch("/api/workspaces");
|
||||
}
|
||||
|
||||
async getWorkspace(id: string): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`);
|
||||
}
|
||||
|
||||
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
|
||||
return this.fetch("/api/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Members
|
||||
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/members`);
|
||||
}
|
||||
|
||||
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<MemberWithUser> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMember(workspaceId: string, memberId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async leaveWorkspace(workspaceId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWorkspace(workspaceId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Skills
|
||||
async listSkills(): Promise<Skill[]> {
|
||||
return this.fetch("/api/skills");
|
||||
}
|
||||
|
||||
async getSkill(id: string): Promise<Skill> {
|
||||
return this.fetch(`/api/skills/${id}`);
|
||||
}
|
||||
|
||||
async createSkill(data: CreateSkillRequest): Promise<Skill> {
|
||||
return this.fetch("/api/skills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateSkill(id: string, data: UpdateSkillRequest): Promise<Skill> {
|
||||
return this.fetch(`/api/skills/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSkill(id: string): Promise<void> {
|
||||
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async importSkill(data: { url: string }): Promise<Skill> {
|
||||
return this.fetch("/api/skills/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listAgentSkills(agentId: string): Promise<Skill[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/skills`);
|
||||
}
|
||||
|
||||
async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
|
||||
await this.fetch(`/api/agents/${agentId}/skills`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Personal Access Tokens
|
||||
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
|
||||
return this.fetch("/api/tokens");
|
||||
}
|
||||
|
||||
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
|
||||
return this.fetch("/api/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async revokePersonalAccessToken(id: string): Promise<void> {
|
||||
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
23
apps/web/shared/api/index.ts
Normal file
23
apps/web/shared/api/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createLogger } from "@/shared/logger";
|
||||
import { ApiClient } from "./client";
|
||||
|
||||
export { ApiClient } from "./client";
|
||||
export type { LoginResponse } from "./client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });
|
||||
|
||||
// Initialize token from localStorage on load
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (token) {
|
||||
api.setToken(token);
|
||||
}
|
||||
const wsId = localStorage.getItem("multica_workspace_id");
|
||||
if (wsId) {
|
||||
api.setWorkspaceId(wsId);
|
||||
}
|
||||
|
||||
}
|
||||
110
apps/web/shared/api/ws-client.ts
Normal file
110
apps/web/shared/api/ws-client.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { WSMessage, WSEventType } from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
export class WSClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private handlers = new Map<WSEventType, Set<EventHandler>>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private hasConnectedBefore = false;
|
||||
private onReconnectCallbacks = new Set<() => void>();
|
||||
private logger: Logger;
|
||||
|
||||
constructor(url: string, options?: { logger?: Logger }) {
|
||||
this.baseUrl = url;
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
setAuth(token: string, workspaceId: string) {
|
||||
this.token = token;
|
||||
this.workspaceId = workspaceId;
|
||||
}
|
||||
|
||||
connect() {
|
||||
const url = new URL(this.baseUrl);
|
||||
if (this.token) url.searchParams.set("token", this.token);
|
||||
if (this.workspaceId)
|
||||
url.searchParams.set("workspace_id", this.workspaceId);
|
||||
|
||||
this.ws = new WebSocket(url.toString());
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.logger.info("connected");
|
||||
if (this.hasConnectedBefore) {
|
||||
for (const cb of this.onReconnectCallbacks) {
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
// ignore reconnect callback errors
|
||||
}
|
||||
}
|
||||
}
|
||||
this.hasConnectedBefore = true;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string) as WSMessage;
|
||||
this.logger.debug("received", msg.type);
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
handler(msg.payload);
|
||||
}
|
||||
} else {
|
||||
this.logger.debug("unhandled event", msg.type);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.logger.warn("disconnected, reconnecting in 3s");
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// Suppress — onclose handles reconnect; errors during StrictMode
|
||||
// double-fire are expected in dev and harmless.
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
// Remove handlers before close to prevent onclose from scheduling a reconnect
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.hasConnectedBefore = false;
|
||||
}
|
||||
|
||||
on(event: WSEventType, handler: EventHandler) {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
onReconnect(callback: () => void) {
|
||||
this.onReconnectCallbacks.add(callback);
|
||||
return () => {
|
||||
this.onReconnectCallbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
send(message: WSMessage) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue