+ );
+ }
+
+ function renderPublicPoolCard() {
+ return (
+
-
>
diff --git a/apps/web/src/services/ai-api.ts b/apps/web/src/services/ai-api.ts
new file mode 100644
index 0000000..813edb7
--- /dev/null
+++ b/apps/web/src/services/ai-api.ts
@@ -0,0 +1,155 @@
+import type { WebSession } from "@/services/session-storage";
+
+export type WebAiChannel = "USER_KEY" | "ASTRBOT" | "PUBLIC_POOL";
+
+export type WebAiRouteAttempt = {
+ channel: WebAiChannel;
+ providerName: string | null;
+ model: string | null;
+ status: "skipped" | "failed" | "success";
+ reasonCode: string | null;
+ reasonMessage: string | null;
+};
+
+export type WebAiBindingSummary = {
+ id: string;
+ channel: WebAiChannel;
+ providerName: string;
+ model: string | null;
+ configId: string | null;
+ configName: string | null;
+ endpoint: string | null;
+ isEnabled: boolean;
+ hasApiKey: boolean;
+ maskedApiKey: string | null;
+ updatedAt: string;
+};
+
+export type WebAiBindingsResponse = {
+ routeOrder: WebAiChannel[];
+ bindings: WebAiBindingSummary[];
+ publicPool: {
+ enabled: boolean;
+ providerName: string | null;
+ model: string | null;
+ endpoint: string | null;
+ hasApiKey: boolean;
+ } | null;
+};
+
+export type UpsertWebAiBindingInput = {
+ channel: Exclude
;
+ providerName?: string;
+ model?: string;
+ configId?: string;
+ configName?: string;
+ endpoint?: string;
+ apiKey?: string;
+ isEnabled?: boolean;
+};
+
+export type WebAiChatResponse = {
+ channel: WebAiChannel;
+ providerName: string;
+ model: string | null;
+ content: string;
+ sessionId: string | null;
+ attempts: WebAiRouteAttempt[];
+};
+
+export class WebAiApiError extends Error {
+ attempts: WebAiRouteAttempt[] | null;
+
+ constructor(message: string, attempts?: WebAiRouteAttempt[] | null) {
+ super(message);
+ this.name = "WebAiApiError";
+ this.attempts = attempts ?? null;
+ }
+}
+
+const DEFAULT_API_BASE_URL = "http://localhost:3000";
+
+function resolveApiBaseUrl(): string {
+ const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
+ if (!envBaseUrl) {
+ return DEFAULT_API_BASE_URL;
+ }
+
+ return envBaseUrl.replace(/\/+$/, "");
+}
+
+function createHeaders(session: WebSession): HeadersInit {
+ return {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${session.accessToken}`,
+ "x-user-id": session.user.id
+ };
+}
+
+async function createApiError(response: Response): Promise {
+ try {
+ const body = (await response.json()) as {
+ message?: string | string[];
+ attempts?: WebAiRouteAttempt[];
+ };
+ const message = Array.isArray(body.message)
+ ? body.message.join(",")
+ : typeof body.message === "string" && body.message.trim().length > 0
+ ? body.message
+ : `请求失败(${response.status})`;
+ return new WebAiApiError(message, body.attempts ?? null);
+ } catch {
+ return new WebAiApiError(`请求失败(${response.status})`);
+ }
+}
+
+export async function listAiBindings(session: WebSession): Promise {
+ const response = await fetch(`${resolveApiBaseUrl()}/ai/bindings`, {
+ method: "GET",
+ headers: createHeaders(session)
+ });
+
+ if (!response.ok) {
+ throw await createApiError(response);
+ }
+
+ return (await response.json()) as WebAiBindingsResponse;
+}
+
+export async function upsertAiBinding(
+ session: WebSession,
+ payload: UpsertWebAiBindingInput
+): Promise {
+ const response = await fetch(`${resolveApiBaseUrl()}/ai/bindings`, {
+ method: "POST",
+ headers: createHeaders(session),
+ body: JSON.stringify(payload)
+ });
+
+ if (!response.ok) {
+ throw await createApiError(response);
+ }
+
+ return (await response.json()) as WebAiBindingSummary;
+}
+
+export async function chatWithAi(
+ session: WebSession,
+ payload: {
+ channel: WebAiChannel;
+ message: string;
+ sessionId?: string;
+ }
+): Promise {
+ const response = await fetch(`${resolveApiBaseUrl()}/ai/chat`, {
+ method: "POST",
+ headers: createHeaders(session),
+ body: JSON.stringify(payload)
+ });
+
+ if (!response.ok) {
+ throw await createApiError(response);
+ }
+
+ return (await response.json()) as WebAiChatResponse;
+}