diff --git a/AGENTS.md b/AGENTS.md index c58f339..eb2ac40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,9 +48,12 @@ npm run build # tsc -b && vite build docker compose up --build # the deployable image, http://localhost:8080 ``` -There is no Experience API yet. The client ships with stubs on -(`stubs.enabled` in config), so chat works end to end with canned replies, each -labelled as stubbed. +The client ships with stubs **off** and calls the Experience API at +`api.baseUrl`. In `npm run dev` that path is proxied to a local API +(`DEV_API_URL`, default `http://localhost:8078`). With no API running, set +`stubs.enabled` to `true` in `src/config/app-config.json` for canned replies, +each labelled as stubbed, and do not commit it: the shipped file is the +deployed one. ## Conventions Naming, git workflow, commit format, PR shape and linting are in diff --git a/README.md b/README.md index d1a6c70..627032c 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,15 @@ is `api.baseUrl` in `config.json`, an absolute path on the app's own origin. The default, `/experience/api`, is the reference deployment's path (see *Serving from a sub-path*). -There is no Experience API yet, so the client ships with a stub layer turned on -(`stubs.enabled` in `config.json`). With it on, every endpoint returns canned -data, nothing leaves the browser, and the chat works end to end — each reply is -labelled as stubbed so fabricated agricultural advice cannot be mistaken for -real. Set `stubs.enabled` to `false` to talk to a real backend. See +The dev server proxies `api.baseUrl` to an Experience API on +`http://localhost:8078`; set `DEV_API_URL` for another address. Without one, +every question shows an error card. + +To work with no API at all, set `stubs.enabled` to `true` in +`src/config/app-config.json`. Every endpoint then returns canned data, nothing +leaves the browser, and the chat works end to end — each reply is labelled as +stubbed so fabricated agricultural advice cannot be mistaken for real. The +shipped file is the deployed one, so do not commit the change. See [`src/lib/api-stubs.ts`](src/lib/api-stubs.ts). ## Git hooks diff --git a/docs/ADR/0000-fork-oan-ui.md b/docs/ADR/0000-fork-oan-ui.md index 1d32223..be73112 100644 --- a/docs/ADR/0000-fork-oan-ui.md +++ b/docs/ADR/0000-fork-oan-ui.md @@ -132,7 +132,7 @@ is doing right now: | Feature | Notes | |---|---| -| **Streaming chat** | The client sends a question and reads the answer from the response body as it arrives. Answers are canned until the Experience API exists | +| **Streaming chat** | The client sends the turn to the Experience API as `POST /v1/chat` and reads the answer back as a stream of events. A stub layer (`stubs.enabled`) can stand in for the API during development and speaks the same stream; it is off by default | | **Markdown answers** | `react-markdown` + `remark-gfm`, with links forced to `target="_blank" rel="noopener noreferrer"` | | **Copy an answer** | Client-side | | **Retry a failed answer** | Client-side | diff --git a/src/__tests__/api-service.test.ts b/src/__tests__/api-service.test.ts new file mode 100644 index 0000000..a84ca58 --- /dev/null +++ b/src/__tests__/api-service.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/config/runtime-config", () => ({ + getConfig: () => ({ api: { baseUrl: "/api" }, stubs: { enabled: false } }) +})); + +import apiService, { ApiError, type ChatTurn } from "@/lib/api-service"; + +/** + * The chat call is the client's whole coupling to the Experience API. These + * tests hold it to the contract (§4–§6) with a fake fetch: what it sends, how + * it reads the event stream, and that every way a turn can fail comes out as + * one ApiError with a code the rest of the client can branch on. + */ +const turn: ChatTurn = { + sessionId: "68a3872f-3f0d-4cf6-99a3-a350132a0080", + messageId: "1ab38d6c-6fdb-4849-8ea1-da5e80a8687c", + query: "And what about tomorrow?", + history: [{ role: "user", text: "Weather today?" }, { role: "assistant", text: "Clear, 31°C." }], + language: { source: "en", target: "en" } +}; + +const STARTED = `event: started\ndata: {"sequence":1,"sessionId":"${turn.sessionId}","messageId":"${turn.messageId}","assistantMessageId":"8d2f4b61-93c7-4e0a-b1f5-2a7c9e3d6f10","traceId":"3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b"}\n\n`; +const DELTAS = `event: delta\ndata: {"sequence":2,"text":"Tomorrow in Nashik "}\n\nevent: delta\ndata: {"sequence":3,"text":"expect light rain after 3 pm."}\n\n`; +const COMPLETED = `event: completed\ndata: {"sequence":4,"sessionId":"${turn.sessionId}","messageId":"${turn.messageId}","assistantMessageId":"8d2f4b61-93c7-4e0a-b1f5-2a7c9e3d6f10","traceId":"3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b","outcome":{"status":"answered","cause":null},"content":[{"type":"text","text":"Tomorrow in Nashik expect light rain after 3 pm.","citations":[]}],"sources":[]}\n\n`; + +const streamResponse = (text: string, contentType = "text/event-stream; charset=utf-8"): Response => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + } + }), + { status: 200, headers: { "content-type": contentType } } + ); + +const jsonResponse = (status: number, body?: unknown): Response => + new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); + +const fetchOnce = (response: Response) => { + const fetchMock = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + +const failure = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + if (error instanceof ApiError) return error; + throw error; + } + throw new Error("expected the call to fail"); +}; + +describe("sendUserQuery", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts the turn as the contract's body, with location only when known", async () => { + const fetchMock = fetchOnce(streamResponse(STARTED + COMPLETED)); + await apiService.sendUserQuery(turn); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/v1/chat"); + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ "Content-Type": "application/json", Accept: "text/event-stream" }); + expect(JSON.parse(init.body as string)).toEqual(turn); + + apiService.setLocationData({ latitude: 20.0059, longitude: 73.7898 }); + const again = fetchOnce(streamResponse(STARTED + COMPLETED)); + await apiService.sendUserQuery(turn); + const [, second] = again.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(second.body as string).location).toEqual({ latitude: 20.0059, longitude: 73.7898 }); + }); + + it("reports started and each delta, then resolves with the completed answer", async () => { + fetchOnce(streamResponse(STARTED + DELTAS + COMPLETED)); + const onStarted = vi.fn(); + const onDelta = vi.fn(); + + const answer = await apiService.sendUserQuery(turn, { onStarted, onDelta }); + + expect(onStarted).toHaveBeenCalledWith({ + assistantMessageId: "8d2f4b61-93c7-4e0a-b1f5-2a7c9e3d6f10", + traceId: "3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b" + }); + expect(onDelta.mock.calls.map((c) => c[0])).toEqual(["Tomorrow in Nashik ", "expect light rain after 3 pm."]); + expect(answer.content).toEqual([ + { type: "text", text: "Tomorrow in Nashik expect light rain after 3 pm.", citations: [] } + ]); + }); + + it("returns a completed answer that carries error, rather than failing", async () => { + const completed = COMPLETED.replace( + '"sources":[]', + '"sources":[],"error":{"code":"provider_unavailable","message":"down","retryable":true,"traceId":"t-1"}' + ); + fetchOnce(streamResponse(STARTED + completed)); + + const answer = await apiService.sendUserQuery(turn); + + expect(answer.error).toMatchObject({ code: "provider_unavailable", retryable: true }); + }); + + it("skips events it does not know", async () => { + fetchOnce(streamResponse(STARTED + 'event: heartbeat\ndata: {"sequence":2}\n\n' + COMPLETED)); + + await expect(apiService.sendUserQuery(turn)).resolves.toMatchObject({ outcome: { status: "answered" } }); + }); + + it("fails with the code from an error event", async () => { + fetchOnce( + streamResponse( + STARTED + + 'event: error\ndata: {"sequence":2,"error":{"code":"timeout","message":"DSS too slow","retryable":true,"traceId":"3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b"}}\n\n' + ) + ); + + const error = await failure(apiService.sendUserQuery(turn)); + + expect(error).toMatchObject({ code: "timeout", retryable: true, traceId: "3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b" }); + }); + + it("fails with the code from an error response body", async () => { + fetchOnce( + jsonResponse(503, { + error: { code: "upstream_unavailable", message: "DSS not ready", retryable: true, traceId: "t-503" } + }) + ); + + const error = await failure(apiService.sendUserQuery(turn)); + + expect(error).toMatchObject({ code: "upstream_unavailable", retryable: true, status: 503, traceId: "t-503" }); + }); + + it("treats a 413 without a contract body as history_too_large", async () => { + fetchOnce(new Response("413 Request Entity Too Large", { status: 413 })); + + const error = await failure(apiService.sendUserQuery(turn)); + + expect(error).toMatchObject({ code: "history_too_large", retryable: false, status: 413 }); + }); + + it("reports any other response without a contract body as unknown, retryable only for 429 and 5xx", async () => { + fetchOnce(jsonResponse(422, { detail: [{ loc: ["body", "sessionId"], msg: "Field required" }] })); + expect(await failure(apiService.sendUserQuery(turn))).toMatchObject({ code: "unknown", retryable: false, status: 422 }); + + fetchOnce(jsonResponse(502)); + expect(await failure(apiService.sendUserQuery(turn))).toMatchObject({ code: "unknown", retryable: true, status: 502 }); + }); + + it("fails as upstream_error when the stream ends with neither completed nor error", async () => { + fetchOnce(streamResponse(STARTED + DELTAS)); + + const error = await failure(apiService.sendUserQuery(turn)); + + expect(error).toMatchObject({ code: "upstream_error", retryable: true, traceId: "3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b" }); + }); + + it("fails as upstream_error when the response is not an event stream", async () => { + fetchOnce(streamResponse("", "text/html")); + + const error = await failure(apiService.sendUserQuery(turn)); + + expect(error).toMatchObject({ code: "upstream_error", retryable: true }); + }); +}); diff --git a/src/__tests__/api-stubs.test.ts b/src/__tests__/api-stubs.test.ts index d7f02cc..d478dd6 100644 --- a/src/__tests__/api-stubs.test.ts +++ b/src/__tests__/api-stubs.test.ts @@ -1,50 +1,101 @@ import { describe, expect, it, vi } from "vitest"; -import { stubGetSuggestions, stubSendUserQuery } from "@/lib/api-stubs"; +import type { ChatTurnRequest, FinalAnswer } from "@/lib/api-service"; +import { stubChatFrames, stubChatResponse, stubGetSuggestions } from "@/lib/api-stubs"; +import { readSseEvents } from "@/lib/sse"; /** - * The stub layer stands in for the Experience API, which does not exist yet. - * These tests cover the two paths the chat screen actually depends on: a reply - * that streams, and a token the auth context will accept. + * The stub stands in for the Experience API, which is not deployed yet. What + * matters is that it speaks the API's own event stream (contract §5.1), so the + * client code that runs on a stubbed reply is the code that will run on a real + * one. These tests read the stub through the real parser to hold it to that. */ -describe("api stubs", () => { - it("streams a reply in chunks that reassemble into the returned text", async () => { - vi.useFakeTimers(); - try { - const chunks: string[] = []; - const pending = stubSendUserQuery("when should I sow wheat?", (chunk) => chunks.push(chunk)); +const request: ChatTurnRequest = { + sessionId: "68a3872f-3f0d-4cf6-99a3-a350132a0080", + messageId: "1ab38d6c-6fdb-4849-8ea1-da5e80a8687c", + query: "when should I sow wheat?", + history: [], + language: { source: "en", target: "en" } +}; - await vi.runAllTimersAsync(); - const result = await pending; +type Parsed = { event: string; data: Record }; - expect(chunks.length).toBeGreaterThan(1); - expect(chunks.join("")).toBe(result.response); - expect(result.status).toBe("success"); - expect(result.qid).toMatch(/^stub-qid-/); - } finally { - vi.useRealTimers(); +const parse = async (frames: string[]): Promise => { + const body = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const frame of frames) controller.enqueue(encoder.encode(frame)); + controller.close(); } }); + const events: Parsed[] = []; + for await (const frame of readSseEvents(body)) { + events.push({ event: frame.event, data: JSON.parse(frame.data) }); + } + return events; +}; + +const completedOf = (events: Parsed[]) => events[events.length - 1]?.data as unknown as FinalAnswer; - it("labels every reply as stubbed, so fabricated advice cannot pass as real", async () => { +describe("chat stub", () => { + it("streams started, deltas and completed in order, numbered from 1", async () => { + const events = await parse(stubChatFrames(request)); + + expect(events[0]?.event).toBe("started"); + expect(events[0]?.data).toMatchObject({ sessionId: request.sessionId, messageId: request.messageId }); + expect(events.slice(1, -1).every((e) => e.event === "delta")).toBe(true); + expect(events[events.length - 1]?.event).toBe("completed"); + expect(events.map((e) => e.data.sequence)).toEqual(events.map((_, i) => i + 1)); + }); + + it("streams the text items as deltas that join to the completed text", async () => { + const events = await parse(stubChatFrames(request)); + const completed = completedOf(events); + + const streamed = events.filter((e) => e.event === "delta").map((e) => e.data.text).join(""); + const textItems = completed.content.filter((item) => item.type === "text").map((item) => item.text).join(""); + expect(streamed).toBe(textItems); + expect(completed.assistantMessageId).toBe(events[0]?.data.assistantMessageId); + }); + + it("labels every reply as stubbed and cycles through a refusal and a failed turn", async () => { + const answers: FinalAnswer[] = []; + for (let i = 0; i < 5; i += 1) { + answers.push(completedOf(await parse(stubChatFrames(request)))); + } + + for (const answer of answers) { + for (const item of answer.content) expect(item.text).toContain("stubbed response"); + } + expect(answers.some((a) => a.content.some((item) => item.type === "refusal"))).toBe(true); + const failed = answers.find((a) => a.error); + expect(failed?.error).toMatchObject({ retryable: true, traceId: failed?.traceId }); + }); + + it("returns an event-stream Response whose body carries the frames", async () => { vi.useFakeTimers(); try { - const seen: string[] = []; - // Cycle further than the number of canned replies to cover them all. - for (let i = 0; i < 4; i += 1) { - const pending = stubSendUserQuery(`question ${i}`); - await vi.runAllTimersAsync(); - seen.push((await pending).response); - } - - for (const reply of seen) { - expect(reply).toContain("stubbed response"); - } + const response = await stubChatResponse(request); + expect(response.ok).toBe(true); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + + const collecting = (async () => { + const seen: string[] = []; + for await (const frame of readSseEvents(response.body as ReadableStream)) seen.push(frame.event); + return seen; + })(); + await vi.runAllTimersAsync(); + + const seen = await collecting; + expect(seen[0]).toBe("started"); + expect(seen[seen.length - 1]).toBe("completed"); } finally { vi.useRealTimers(); } }); +}); +describe("other stubs", () => { it("returns suggestions in the shape the chat store expects", async () => { vi.useFakeTimers(); try { diff --git a/src/components/screens-component/chat-screen/components/bubbles/chat-types.ts b/src/components/screens-component/chat-screen/components/bubbles/chat-types.ts index cda24d3..95e9c59 100644 --- a/src/components/screens-component/chat-screen/components/bubbles/chat-types.ts +++ b/src/components/screens-component/chat-screen/components/bubbles/chat-types.ts @@ -21,7 +21,8 @@ export type TextMessage = MessageBase & { export type CardMessage = MessageBase & { type: "card"; - qid?: string; + /** The API's trace id for the turn, for support. */ + traceId?: string; title?: string; body: string; actions?: { id: string; label: string }[]; diff --git a/src/config/app-config.json b/src/config/app-config.json index ad3a75b..c5c2980 100644 --- a/src/config/app-config.json +++ b/src/config/app-config.json @@ -1,6 +1,6 @@ { "stubs": { - "enabled": true + "enabled": false }, "api": { "baseUrl": "/experience/api" diff --git a/src/hooks/store/chat/index.ts b/src/hooks/store/chat/index.ts index 64412cd..0363897 100644 --- a/src/hooks/store/chat/index.ts +++ b/src/hooks/store/chat/index.ts @@ -1,13 +1,14 @@ -import { create } from "zustand"; +import { create, type StoreApi } from "zustand"; import type { + CardMessage, ChatMessage, TextMessage } from "@/components/screens-component/chat-screen/components/bubbles/chat-types"; -import { APP_NAME, LANGUAGES, type LanguageCode } from "@/components/screens-component/chat-screen/config"; +import { LANGUAGES, type LanguageCode } from "@/components/screens-component/chat-screen/config"; import { FEATURES } from "@/lib/config/features"; import { type Suggestion } from "@/components/screens-component/chat-screen/api/suggestions-api"; -import apiService from "@/lib/api-service"; +import apiService, { ApiError, type ChatStreamHandlers, type FinalAnswer } from "@/lib/api-service"; import { shuffle, randomPick } from "@/lib/qa-utils"; import type { ToastType } from "@/components/screens-component/chat-screen/components/toast"; import { neutralizeHtmlMarkup } from "@/lib/security/html"; @@ -193,58 +194,108 @@ function makeUserMessage(text: string): TextMessage { }; } -function makeAssistantMessage( - text: string, - isError?: boolean, - showListenRow = false, - qid?: string, - failedUserText?: string, - failedLanguage?: string, - responseLanguage?: LanguageCode -): ChatMessage { - return { - id: crypto.randomUUID(), - role: "assistant", - type: "card", - qid, - body: text, - createdAt: new Date().toISOString(), - showListenRow, - isError, - failedUserText, - failedLanguage, - responseLanguage - }; -} - function getResponseLanguage(language: string): LanguageCode | undefined { return language in LANGUAGES ? (language as LanguageCode) : undefined; } -function getErrorQid(error: unknown): string | undefined { - const qid = (error as { qid?: unknown })?.qid; - return typeof qid === "string" && qid.trim() ? qid : undefined; +/** The assistant's final text: every content item, in order, joined by a blank line (§4.1). */ +function answerText(answer: FinalAnswer): string { + return answer.content.map((item) => item.text).join("\n\n"); } -function warnMissingBackendQid(context: string, fallbackQid: string) { - console.warn(`Backend did not return X-QID for ${context}; using message id fallback for telemetry`, { - fallbackQid - }); +/** Replace the message with `card.id`, or append it when it is not there yet. */ +function upsertMessage(messages: ChatMessage[], card: ChatMessage): ChatMessage[] { + const index = messages.findIndex((m) => m.id === card.id); + if (index === -1) return [...messages, card]; + const updated = [...messages]; + updated[index] = card; + return updated; } -function attachQidToLatestAssistantMessage(messages: ChatMessage[], qid?: string): ChatMessage[] { - if (!qid) return messages; +type TurnContext = { + /** What Retry sends again. */ + failedUserText: string; + language: string; + responseLanguage?: LanguageCode; + /** Shown when the turn failed and the API sent no words of its own. */ + genericErrorMessage: string; +}; + +/** + * Run one turn and keep the assistant bubble in step with the stream. + * + * The bubble is drawn on the first delta, not on `started`: until there is + * text the typing indicator stays, where an empty bubble would replace it with + * nothing. Its id is the API's `assistantMessageId`. On `completed` the + * streamed text is replaced by the answer's `content`, which is authoritative + * (§5.1). Resolves true when the turn produced an answer without `error`. + */ +async function streamTurn( + set: StoreApi["setState"], + send: (handlers: ChatStreamHandlers) => Promise, + ctx: TurnContext +): Promise { + let started: { assistantMessageId: string; traceId: string } | null = null; + let cardId: string | null = null; + let createdAt: string | null = null; + let streamingText = ""; + + const draw = (card: Omit) => { + cardId ??= started?.assistantMessageId ?? crypto.randomUUID(); + createdAt ??= new Date().toISOString(); + const message: CardMessage = { + id: cardId, + role: "assistant", + type: "card", + createdAt, + traceId: started?.traceId, + responseLanguage: ctx.responseLanguage, + ...card + }; + set((state) => ({ messages: upsertMessage(state.messages, message), isAssistantTyping: false })); + }; + + const drawFailure = (traceId: string | undefined, retryable: boolean, body: string) => + draw({ + body, + traceId, + isError: true, + showListenRow: false, + failedUserText: retryable ? ctx.failedUserText : undefined, + failedLanguage: retryable ? ctx.language : undefined + }); + + try { + const answer = await send({ + onStarted: (event) => { + started = event; + }, + onDelta: (text) => { + streamingText += text; + draw({ body: streamingText, showListenRow: true }); + } + }); - for (let index = messages.length - 1; index >= 0; index--) { - const message = messages[index]; - if (message?.role === "assistant" && message.type === "card") { - const updated = [...messages]; - updated[index] = { ...message, qid }; - return updated; + const text = answerText(answer); + if (answer.error) { + // The DSS finished the turn but something it needed was down (§5.3). + // Its own words when it sent any, the client's otherwise. + drawFailure(answer.error.traceId ?? answer.traceId, answer.error.retryable, text || ctx.genericErrorMessage); + set({ isInputLocked: false }); + return false; } - } - return messages; + draw({ body: text, showListenRow: true }); + set({ isInputLocked: false }); + return true; + } catch (error) { + console.error("Error sending turn:", error); + // A failure fetch itself reports, such as no network, is worth a retry. + const apiError = error instanceof ApiError ? error : null; + drawFailure(apiError?.traceId, apiError ? apiError.retryable : true, ctx.genericErrorMessage); + set({ isInputLocked: false }); + return false; + } } function makeImageMessage(imageUrl: string, caption?: string): ChatMessage { @@ -408,99 +459,38 @@ export const useChatStore = create((set, get) => ({ apiService.setSessionId(currentSession); } - try { - // In a real app we'd detect language, here we use what's passed - let streamingText = ""; - - const response = await apiService.sendUserQuery( - safeText, - currentSession, - language, // source - language, // target - (chunk) => { - streamingText += chunk; - set((state) => { - const lastMsg = state.messages[state.messages.length - 1]; - if (lastMsg && lastMsg.role === "assistant" && lastMsg.type === "card") { - return { - messages: [...state.messages.slice(0, -1), { ...lastMsg, body: streamingText, showListenRow: true, responseLanguage }], - isAssistantTyping: false - }; - } else { - return { - messages: [ - ...state.messages, - makeAssistantMessage(streamingText, false, true, undefined, undefined, undefined, responseLanguage) - ], - isAssistantTyping: false - }; - } - }); - } - // Note: input stays locked until sendUserQuery fully resolves (after all stream chunks) - ); - - set((state) => ({ - messages: attachQidToLatestAssistantMessage(state.messages, response.qid), - isAssistantTyping: false, - isInputLocked: false - })); - - if (FEATURES.suggestions) { - const suggestions = await apiService.getSuggestions(currentSession, language); - set({ - suggestions: suggestions.map((s) => ({ - id: crypto.randomUUID(), - text: s.question, - label: s.question - })) - }); + const answered = await streamTurn( + set, + (handlers) => + apiService.sendUserQuery( + { + sessionId: currentSession, + messageId: userMessage.id, + query: safeText, + history: [], + language: { source: language, target: language } + }, + handlers + ), + { + failedUserText: safeText, + language, + responseLanguage, + genericErrorMessage: t + ? String(t("chatErrorMessage")) + : "Sorry, there was an error processing your request. Please try again." } - } catch (error: any) { - console.error("Error sending text:", error); - set({ isAssistantTyping: false, isInputLocked: false }); - - const isRateLimitError = - error?.status === 429 || - error?.response?.status === 429 || - (error instanceof Error && error.message.includes("Rate limit")); - - if (isRateLimitError) { - const limitMessage = t - ? t("limitMessage", { appName: APP_NAME }) - : `Dear user, you have reached the allotted question limit for today. You may continue to explore the other features of the ${APP_NAME} app.`; - set((state) => ({ - messages: (() => { - const backendQid = getErrorQid(error); - const message = makeAssistantMessage(limitMessage, true, true, backendQid, undefined, undefined, responseLanguage); - if (!backendQid) warnMissingBackendQid("text rate-limit error", message.id); - return [...state.messages, message]; - })() - })); - - } else { - // Show error as an in-chat message with retry capability - const errorMessage = t - ? t("chatErrorMessage") || "Sorry, there was an error processing your request. Please try again." - : "Sorry, there was an error processing your request. Please try again."; - set((state) => ({ - messages: (() => { - const backendQid = getErrorQid(error); - const message = makeAssistantMessage( - errorMessage as string, - true, - false, - backendQid, - safeText, - language, - responseLanguage - ); - if (!backendQid) warnMissingBackendQid("text chat error", message.id); - return [...state.messages, message]; - })() - })); + ); - } + if (answered && FEATURES.suggestions) { + const suggestions = await apiService.getSuggestions(currentSession, language); + set({ + suggestions: suggestions.map((s) => ({ + id: crypto.randomUUID(), + text: s.question, + label: s.question + })) + }); } }, @@ -570,97 +560,38 @@ export const useChatStore = create((set, get) => ({ apiService.setSessionId(currentSession); } - try { - let streamingText = ""; - - const response = await apiService.sendImageQuery( - uploadFile, - currentSession, + const answered = await streamTurn( + set, + (handlers) => + apiService.sendImageQuery( + uploadFile, + { + sessionId: currentSession, + messageId: imageMessage.id, + history: [], + language: { source: language, target: language } + }, + handlers + ), + { + failedUserText: `[Image] ${uploadFile.name}`, language, - language, - (chunk) => { - streamingText += chunk; - set((state) => { - const lastMsg = state.messages[state.messages.length - 1]; - if (lastMsg && lastMsg.role === "assistant" && lastMsg.type === "card") { - return { - messages: [...state.messages.slice(0, -1), { ...lastMsg, body: streamingText, showListenRow: true, responseLanguage }], - isAssistantTyping: false - }; - } else { - return { - messages: [ - ...state.messages, - makeAssistantMessage(streamingText, false, true, undefined, undefined, undefined, responseLanguage) - ], - isAssistantTyping: false - }; - } - }); - } - // Note: input stays locked until sendImageQuery fully resolves (after all stream chunks) - ); - - set((state) => ({ - messages: attachQidToLatestAssistantMessage(state.messages, response.qid), - isAssistantTyping: false, - isInputLocked: false - })); - - if (FEATURES.suggestions) { - const suggestions = await apiService.getSuggestions(currentSession, language); - set({ - suggestions: suggestions.map((s) => ({ - id: crypto.randomUUID(), - text: s.question, - label: s.question - })) - }); + responseLanguage, + genericErrorMessage: t + ? String(t("imageUpload.analysisFailed")) + : "Sorry, there was an error analyzing your image. Please try again." } - } catch (error: any) { - console.error("Error sending image:", error); - set({ isAssistantTyping: false, isInputLocked: false }); - - const isRateLimitError = - error?.status === 429 || - error?.response?.status === 429 || - (error instanceof Error && error.message.includes("Rate limit")); - - if (isRateLimitError) { - const limitMessage = t - ? t("limitMessage", { appName: APP_NAME }) - : `Dear user, you have reached the allotted question limit for today. You may continue to explore the other features of the ${APP_NAME} app.`; - set((state) => ({ - messages: (() => { - const backendQid = getErrorQid(error); - const message = makeAssistantMessage(limitMessage, true, true, backendQid, undefined, undefined, responseLanguage); - if (!backendQid) warnMissingBackendQid("image rate-limit error", message.id); - return [...state.messages, message]; - })() - })); - - } else { - const errorMessage = t - ? t("imageUpload.analysisFailed") || "Sorry, there was an error analyzing your image. Please try again." - : "Sorry, there was an error analyzing your image. Please try again."; - set((state) => ({ - messages: (() => { - const backendQid = getErrorQid(error); - const message = makeAssistantMessage( - errorMessage as string, - true, - false, - backendQid, - `[Image] ${uploadFile.name}`, - language, - responseLanguage - ); - if (!backendQid) warnMissingBackendQid("image chat error", message.id); - return [...state.messages, message]; - })() - })); + ); - } + if (answered && FEATURES.suggestions) { + const suggestions = await apiService.getSuggestions(currentSession, language); + set({ + suggestions: suggestions.map((s) => ({ + id: crypto.randomUUID(), + text: s.question, + label: s.question + })) + }); } }, diff --git a/src/lib/api-service.ts b/src/lib/api-service.ts index d067820..656759f 100644 --- a/src/lib/api-service.ts +++ b/src/lib/api-service.ts @@ -2,24 +2,193 @@ import axios, { AxiosInstance, AxiosResponse } from 'axios'; import { getConfig } from '@/lib/config/runtime-config'; import { stubsEnabled, + stubChatResponse, stubGetSuggestions, stubGetTranscript, - stubSendUserQuery, stubTranscribeAudio, stubUploadImage, } from '@/lib/api-stubs'; +import { readSseEvents } from '@/lib/sse'; export interface LocationData { latitude: number; longitude: number; } -export interface ChatResponse { - response: string; - status: string; - qid?: string; +// --- The chat contract: POST /v1/chat ------------------------------------ +// Shapes follow docs/implementation/experience-api-contract.md §4–§6. The +// client branches on `content[].type` and on `error`, never on +// `outcome.status`, so a status it has never seen still renders. + +export interface HistoryItem { + role: 'user' | 'assistant'; + text: string; +} + +/** One turn from the client. `location` is added here when the browser gave one. */ +export interface ChatTurn { + sessionId: string; + /** Id of the message in `query`. A retry sends the same one again. */ + messageId: string; + query: string; + /** Every completed exchange so far, oldest first. The API stores nothing. */ + history: HistoryItem[]; + language: { source: string; target: string }; +} + +export type ChatTurnRequest = ChatTurn & { location?: LocationData }; + +export interface Citation { + sourceId: string; + start: number; + end: number; +} + +export type AnswerContent = + | { type: 'text'; text: string; citations?: Citation[] } + | { type: 'refusal'; text: string }; + +export interface AnswerSource { + id: string; + name: string; + url?: string; } +/** The one error shape: HTTP error bodies, the `error` event and `FinalAnswer.error`. */ +export interface ApiErrorBody { + code: string; + /** For logs. English. Never shown to the user as-is. */ + message: string; + retryable: boolean; + retryAfterSeconds?: number; + traceId?: string; +} + +/** The `completed` event, and the whole answer. Authoritative over the deltas. */ +export interface FinalAnswer { + sessionId: string; + messageId: string; + assistantMessageId: string; + traceId: string; + outcome: { status: string; cause: string | null }; + /** In order. Empty only when `error` is present. */ + content: AnswerContent[]; + sources: AnswerSource[]; + /** Present when the turn failed after the DSS accepted it. */ + error?: ApiErrorBody; +} + +export interface ChatStreamHandlers { + onStarted?: (started: { assistantMessageId: string; traceId: string }) => void; + onDelta?: (text: string) => void; +} + +/** + * A failed call. `code` is what the client branches on (§6.1). It comes from + * the API where the API sent one, and is made up here where it did not: + * `history_too_large` for a bare 413, `upstream_error` for a broken stream, + * `unknown` for any other response without a contract body. + */ +export class ApiError extends Error { + readonly code: string; + readonly retryable: boolean; + readonly retryAfterSeconds?: number; + readonly traceId?: string; + /** HTTP status, when the failure was a response rather than a stream event. */ + readonly status?: number; + + constructor(body: ApiErrorBody, status?: number) { + super(body.message); + this.name = 'ApiError'; + this.code = body.code; + this.retryable = body.retryable; + this.retryAfterSeconds = body.retryAfterSeconds; + this.traceId = body.traceId; + this.status = status; + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const isErrorBody = (value: unknown): value is ApiErrorBody => + isRecord(value) && typeof value.code === 'string' && typeof value.retryable === 'boolean'; + +const upstreamError = (message: string, traceId?: string): ApiError => + new ApiError({ code: 'upstream_error', message, retryable: true, traceId }); + +/** Turn a non-OK response into an ApiError, with fallbacks for a body that is not ours. */ +const errorFromResponse = async (response: Response): Promise => { + const body: unknown = await response.json().catch(() => undefined); + if (isRecord(body) && isErrorBody(body.error)) { + return new ApiError(body.error, response.status); + } + if (response.status === 413) { + // Something in front of the API rejected the body size before the API + // could say so (§6.2). Same cause, same message to the user. + return new ApiError( + { code: 'history_too_large', message: `HTTP 413 without a contract body`, retryable: false }, + 413 + ); + } + return new ApiError( + { + code: 'unknown', + message: `HTTP ${response.status} without a contract body`, + retryable: response.status === 429 || response.status >= 500, + }, + response.status + ); +}; + +/** + * Read the event stream to its end. Exactly one of `completed` or `error` + * ends every stream (§5.1); a stream that closes without either is a failure, + * never a success — the connection closing proves nothing. + */ +const readAnswer = async ( + body: ReadableStream, + handlers: ChatStreamHandlers +): Promise => { + let traceId: string | undefined; + + for await (const frame of readSseEvents(body)) { + let data: unknown; + try { + data = JSON.parse(frame.data); + } catch { + throw upstreamError(`Event "${frame.event}" did not carry JSON`, traceId); + } + if (!isRecord(data)) throw upstreamError(`Event "${frame.event}" was not an object`, traceId); + + switch (frame.event) { + case 'started': + if (typeof data.traceId === 'string') traceId = data.traceId; + handlers.onStarted?.({ + assistantMessageId: String(data.assistantMessageId), + traceId: String(data.traceId), + }); + break; + case 'delta': + if (typeof data.text === 'string') handlers.onDelta?.(data.text); + break; + case 'completed': + if (!Array.isArray(data.content)) throw upstreamError('completed carried no content array', traceId); + return data as unknown as FinalAnswer; + case 'error': + if (!isErrorBody(data.error)) throw upstreamError('error event carried no error body', traceId); + throw new ApiError({ traceId, ...data.error }); + default: + // Forward compatible: an event this client does not know is skipped. + break; + } + } + + throw upstreamError('The stream ended without a completed or error event', traceId); +}; + +// --- Older endpoints, all switched off in config --------------------------- + export interface TranscriptionResponse { text: string; lang_code: string; @@ -36,24 +205,10 @@ interface TTSResponse { session_id: string; } -; - interface ImageUploadResponse { image_id: string; } -const CHAT_QID_HEADER = "X-QID"; -const SSE_KEEPALIVE_MARKER = "SSE_KEEPALIVE"; - -// Constants - -const stripSseKeepAliveMarkers = (chunk: string): string => - chunk - .split(/\r?\n/) - .filter((line) => line.trim() !== SSE_KEEPALIVE_MARKER) - .join('\n'); - - /** * Where the API lives: `api.baseUrl` from config.json, used as written. An * absolute path such as `/api` or `/exp/api` keeps every call on the app's @@ -81,102 +236,44 @@ class ApiService { return this.axios; } - async sendUserQuery( - msg: string, - session: string, - sourceLang: string, - targetLang: string, - onStreamData?: (_data: string) => void, - onResponseStarted?: () => void - ): Promise { - try { - if (stubsEnabled()) { - return await stubSendUserQuery(msg, onStreamData, onResponseStarted); - } - - - const params = { - session_id: session, - query: msg, - source_lang: sourceLang, - target_lang: targetLang, - ...(this.locationData && { - latitude: String(this.locationData.latitude), - longitude: String(this.locationData.longitude) - }) - }; + /** + * Send one chat turn and stream the answer back. + * + * Resolves with the `completed` answer, which replaces whatever the deltas + * built up. Rejects with an ApiError for an error response, an `error` + * event, or a stream that broke. A `completed` that carries `error` is not + * a rejection: the caller reads `error` from it (§5.3). + * + * With stubs on, only the network call is swapped; the stub answers with the + * same event stream and everything after this line is the real code. + */ + async sendUserQuery(turn: ChatTurn, handlers: ChatStreamHandlers = {}): Promise { + const request: ChatTurnRequest = { + ...turn, + ...(this.locationData && { location: this.locationData }), + }; - if (onStreamData) { - // Handle streaming response - const response = await fetch(`${apiBaseUrl()}/chat/?${new URLSearchParams(params)}`, { - method: 'GET' + const response = stubsEnabled() + ? await stubChatResponse(request) + : await fetch(`${apiBaseUrl()}/v1/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify(request), }); + if (!response.ok) { + throw await errorFromResponse(response); + } - if (!response.ok) { - const responseQid = response.headers.get(CHAT_QID_HEADER) || undefined; - if (response.status === 429) { - const error = new Error('Rate limit exceeded'); - (error as any).status = 429; - if (responseQid) (error as any).qid = responseQid; - throw error; - } - const error = new Error(`HTTP error! status: ${response.status}`); - (error as any).status = response.status; - if (responseQid) (error as any).qid = responseQid; - throw error; - } - - const responseQid = response.headers.get(CHAT_QID_HEADER) || undefined; - onResponseStarted?.(); - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('Response body is not readable'); - } - - let fullResponse = ''; - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - const displayChunk = stripSseKeepAliveMarkers(chunk); - - if (chunk.includes(SSE_KEEPALIVE_MARKER) && !displayChunk.trim()) { - continue; - } - - fullResponse += displayChunk; - onStreamData(displayChunk); - } - - return { response: fullResponse, status: 'success', qid: responseQid }; - } else { - // Regular non-streaming request - const config = { - params, - }; - const response = await this.axiosInstance.get('chat/', config); - const responseQid = response.headers[CHAT_QID_HEADER.toLowerCase()] as string | undefined; - onResponseStarted?.(); - return { - ...response.data, - qid: response.data?.qid || responseQid - }; - } - } catch (error) { - const responseQid = axios.isAxiosError(error) - ? error.response?.headers?.[CHAT_QID_HEADER.toLowerCase()] - : undefined; - if (responseQid) { - (error as any).qid = responseQid; - } - console.error('Error sending user query:', error); - throw error; + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.includes('text/event-stream') || !response.body) { + throw upstreamError(`Expected an event stream, got "${contentType || 'no content type'}"`); } + + return readAnswer(response.body, handlers); } private parseImageUploadResponse(payload: unknown): ImageUploadResponse { @@ -221,12 +318,9 @@ class ApiService { async sendImageQuery( imageFile: File, - session: string, - sourceLang: string, - targetLang: string, - onStreamData?: (_data: string) => void, - onResponseStarted?: () => void - ): Promise { + turn: Omit, + handlers: ChatStreamHandlers = {} + ): Promise { // Step 1: Upload image to get image ID const uploadResult = await this.uploadImage(imageFile); const imageId = uploadResult.image_id; @@ -235,7 +329,7 @@ class ApiService { // The backend resolves this ID to a localhost image URL internally. const query = `please do the pest analysis for this image ${imageId}`; - return this.sendUserQuery(query, session, sourceLang, targetLang, onStreamData, onResponseStarted); + return this.sendUserQuery({ ...turn, query }, handlers); } async getSuggestions(session: string, targetLang: string = 'mr'): Promise { diff --git a/src/lib/api-stubs.ts b/src/lib/api-stubs.ts index 835fe9d..b1db73c 100644 --- a/src/lib/api-stubs.ts +++ b/src/lib/api-stubs.ts @@ -1,13 +1,14 @@ /** - * Local stub layer — returns canned objects instead of hitting the backend. + * Local stub layer — answers with canned data instead of calling the backend. * * Controlled by the `stubs` block in config.json. When disabled, every export - * here is inert and the app talks to the real API exactly as before. + * here is inert and the app talks to the real API. * - * This exists so the UI can be run with no backend — there is no Experience API - * yet. It is a stepping stone to a real mock REST service: each - * function below mirrors one endpoint's response shape, so the bodies can be - * lifted into an actual server later. + * This exists so the UI can be run with no backend. For chat, the stub does + * not short-circuit the client: it returns a Response carrying the same + * event stream the Experience API sends (contract §5.1), so the real parser + * and event handling run on every stubbed reply. The older endpoints below + * return plain objects, because the surfaces that call them are switched off. * * Every stubbed chat reply ends with a line saying so, because these are * plausible-looking agricultural answers and must never be mistaken for real @@ -16,7 +17,9 @@ import { getConfig } from '@/lib/config/runtime-config'; import type { - ChatResponse, + ApiErrorBody, + ChatTurnRequest, + FinalAnswer, SuggestionItem, TranscriptionResponse, } from '@/lib/api-service'; @@ -49,10 +52,27 @@ const stubLog = (endpoint: string, detail?: unknown): void => { const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -// --- Endpoint stubs ---------------------------------------------------- +// --- POST /v1/chat ------------------------------------------------------- -const CHAT_REPLIES: string[] = [ - `### Wheat sowing advice +const STUB_NOTE = '_This is a stubbed response — no backend was contacted._'; + +type StubReply = Pick & { + error?: Omit; +}; + +/** + * One reply per kind of outcome the client has to draw (§5.3), in a cycle: + * three answers, a refusal, and a turn that failed after the DSS accepted it. + * The last one shows Retry and stays out of history, so both can be checked + * with stubs on. + */ +const CHAT_REPLIES: StubReply[] = [ + { + outcome: { status: 'answered', cause: null }, + content: [ + { + type: 'text', + text: `### Wheat sowing advice For **Pune district**, the recommended sowing window for irrigated wheat is **1–15 November**. @@ -63,9 +83,18 @@ For **Pune district**, the recommended sowing window for irrigated wheat is **1 2. Give the first irrigation 20–25 days after sowing (crown root stage) — this one matters most for yield. 3. Watch for yellow rust from late December onward. -_This is a stubbed response — no backend was contacted._`, - - `### Mandi prices +${STUB_NOTE}`, + citations: [{ sourceId: 'src_1', start: 0, end: 24 }], + }, + ], + sources: [{ id: 'src_1', name: 'Package of Practices, MPKV Rahuri', url: 'https://mpkv.ac.in/' }], + }, + { + outcome: { status: 'answered', cause: null }, + content: [ + { + type: 'text', + text: `### Mandi prices Indicative rates for your area today: @@ -77,9 +106,18 @@ Indicative rates for your area today: Prices are per quintal and vary by grade and moisture content. -_This is a stubbed response — no backend was contacted._`, - - `### PM-Kisan Samman Nidhi +${STUB_NOTE}`, + citations: [], + }, + ], + sources: [], + }, + { + outcome: { status: 'answered', cause: null }, + content: [ + { + type: 'text', + text: `### PM-Kisan Samman Nidhi Eligible landholding farmer families receive **₹6,000 per year**, paid in three equal instalments of ₹2,000. @@ -90,46 +128,119 @@ Eligible landholding farmer families receive **₹6,000 per year**, paid in thre If an instalment is pending, the usual cause is incomplete e-KYC or a name mismatch with bank records. -_This is a stubbed response — no backend was contacted._`, +${STUB_NOTE}`, + citations: [], + }, + ], + sources: [], + }, + { + outcome: { status: 'rejected', cause: 'out_of_scope' }, + content: [ + { + type: 'refusal', + text: `I can help with farming questions — crops, livestock, weather, market prices and government schemes — but not with this one. + +${STUB_NOTE}`, + }, + ], + sources: [], + }, + { + outcome: { status: 'unavailable', cause: 'provider_unavailable' }, + content: [ + { + type: 'text', + text: `The market price service is not responding right now. Please try again in a few minutes. + +${STUB_NOTE}`, + citations: [], + }, + ], + sources: [], + error: { + code: 'provider_unavailable', + message: 'Upstream price provider timed out after 30s', + retryable: true, + retryAfterSeconds: 30, + }, + }, ]; let chatReplyIndex = 0; +const frame = (event: string, data: unknown): string => + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + /** - * GET /api/chat/ - * - * Emulates the streaming endpoint: the reply is pushed through `onStreamData` - * in small chunks, the same way the real SSE response is consumed. + * The frames one stubbed turn streams, as the API would write them (§5.1): + * `started`, a comment line as a keep-alive would be, a `delta` per word of + * each text item, then `completed` with the whole answer. Pure, so the + * stream's content can be tested without its timing. */ -export const stubSendUserQuery = async ( - query: string, - onStreamData?: (_data: string) => void, - onResponseStarted?: () => void -): Promise => { - stubLog('GET /api/chat/', { query }); - const qid = `stub-qid-${Date.now()}`; - - const reply = CHAT_REPLIES[chatReplyIndex % CHAT_REPLIES.length] as string; +export const stubChatFrames = (request: ChatTurnRequest): string[] => { + const reply = CHAT_REPLIES[chatReplyIndex % CHAT_REPLIES.length] as StubReply; chatReplyIndex += 1; - await delay(400); - onResponseStarted?.(); + const ids = { + sessionId: request.sessionId, + messageId: request.messageId, + assistantMessageId: crypto.randomUUID(), + traceId: crypto.randomUUID(), + }; + let sequence = 0; + const next = () => ++sequence; - if (!onStreamData) { - await delay(300); - return { response: reply, status: 'success', qid }; - } + const frames = [frame('started', { sequence: next(), ...ids }), ': ping\n\n']; - // Stream in word-sized chunks so the typing indicator behaves realistically. - const chunks = reply.match(/\S+\s*/g) ?? [reply]; - for (const chunk of chunks) { - await delay(18); - onStreamData(chunk); + for (const item of reply.content) { + if (item.type !== 'text') continue; // a refusal arrives whole, in completed + for (const word of item.text.match(/\S+\s*/g) ?? []) { + frames.push(frame('delta', { sequence: next(), text: word })); + } } - return { response: reply, status: 'success', qid }; + const answer: FinalAnswer = { + ...ids, + outcome: reply.outcome, + content: reply.content, + sources: reply.sources, + ...(reply.error && { error: { ...reply.error, traceId: ids.traceId } }), + }; + frames.push(frame('completed', { sequence: next(), ...answer })); + + return frames; }; +/** + * What fetch would return for POST /v1/chat: a 200 whose body streams the + * frames above, a word at a time, so the typing indicator and the streaming + * text behave as they would against the real API. + */ +export const stubChatResponse = async (request: ChatTurnRequest): Promise => { + stubLog('POST /v1/chat', request); + const frames = stubChatFrames(request); + const encoder = new TextEncoder(); + + const body = new ReadableStream({ + async start(controller) { + await delay(400); + for (const chunk of frames) { + controller.enqueue(encoder.encode(chunk)); + await delay(18); + } + controller.close(); + }, + }); + + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); +}; + +// --- Older endpoints, switched off ------------------------------------------ + /** POST /api/image/upload */ export const stubUploadImage = async (file: File): Promise<{ image_id: string }> => { stubLog('POST /api/image/upload', { name: file.name, size: file.size }); diff --git a/vite.config.ts b/vite.config.ts index 258a6d9..932c539 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,6 +25,7 @@ const virtualRouteFileChangeReloadPlugin: PluginOption = { * plugin puts it on the web root in both dev and build from that one source. */ const APP_CONFIG_SOURCE = path.resolve(__dirname, "src/config/app-config.json"); +const apiBaseUrl: string = JSON.parse(fs.readFileSync(APP_CONFIG_SOURCE, "utf-8")).api.baseUrl; const appConfigPlugin: PluginOption = { name: "serve-app-config", @@ -72,6 +73,17 @@ export default defineConfig({ virtualRouteFileChangeReloadPlugin ], server: { - port: 3000 + port: 3000, + // With stubs off, calls to api.baseUrl go to a local Experience API the + // way the front proxy routes them in deployment: same origin for the + // browser, the prefix stripped before it reaches the API. The path is + // read from the same config file the app uses, so the two cannot drift. + // The API's own port is the default; DEV_API_URL overrides it. + proxy: { + [apiBaseUrl]: { + target: process.env.DEV_API_URL || "http://localhost:8078", + rewrite: (requestPath) => requestPath.slice(apiBaseUrl.length) + } + } } });