From e65a1c2bddb2616095f30a6d4d4ecb93e4782162 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Thu, 24 Sep 2026 00:36:46 +0530 Subject: [PATCH 1/3] feat: send chat turns to POST /v1/chat and read the answer as events The client spoke the old backend's shape: GET /chat/ with the question in the query string, the answer read as raw text chunks with a magic keep-alive word filtered out, a trace id in an X-QID header, and "connection closed" taken as success. None of that survives the Experience API contract (docs/implementation/experience-api-contract.md). sendUserQuery now posts the contract's body (sessionId, messageId, query, history, language, location when known) and reads the reply as server-sent events: started, delta, completed, error. `completed` is the answer and replaces the streamed text. Every failure is one ApiError with a code the rest of the client can branch on. Where the API sent no code the client makes one up: history_too_large for a bare 413, upstream_error for a stream that ends without completed or error or is not an event stream at all, unknown for anything else. The stub no longer bypasses this. It returns a Response whose body is the same event stream, so the parser and event handling run on every stubbed reply too. Its cycle now covers a refusal and a turn that failed after the DSS accepted it, so Retry can be seen with stubs on. In the store, sendText and sendImage shared two copies of the streaming and error code; they now share one turn runner. The bubble is drawn on the first delta, with the API's assistantMessageId as its id; traceId replaces qid. The "daily question limit" message for 429 goes: under the contract 429 means busy and is retryable. Errors still show the one generic message; copy per code follows in a later change, as does sending the real history, which is [] here. Verified: lint, typecheck, knip, 45 tests and the build pass. The real call was also run against the Experience API on localhost and streamed started, two deltas and completed through this code. Co-Authored-By: Claude Fable 5.1 --- src/__tests__/api-service.test.ts | 173 ++++++++ src/__tests__/api-stubs.test.ts | 111 ++++-- .../components/bubbles/chat-types.ts | 3 +- src/hooks/store/chat/index.ts | 377 +++++++----------- src/lib/api-service.ts | 328 +++++++++------ src/lib/api-stubs.ts | 195 +++++++-- 6 files changed, 774 insertions(+), 413 deletions(-) create mode 100644 src/__tests__/api-service.test.ts 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/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 }); From b5d54fa8fed5e400c034dbd1dab17b76e4b4bfc5 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Thu, 24 Sep 2026 00:37:46 +0530 Subject: [PATCH 2/3] chore: proxy the configured API path to a local Experience API in dev With stubs off, the dev server answered POST /experience/api/v1/chat itself with index.html, which the client reported as upstream_error. Now that an Experience API runs locally there is something real to talk to. The dev server forwards `api.baseUrl` the way the front proxy does in deployment: same origin for the browser, the prefix stripped before the API sees the path. 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 and DEV_API_URL overrides it. Nothing changes with stubs on, which is still the default. Verified: a POST through localhost:3000/experience/api/v1/chat streams the API's started, delta and completed events back. Co-Authored-By: Claude Fable 5.1 --- vite.config.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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) + } + } } }); From fb411f5ec7736e2d5cd56955a1c89cff26f9c2a1 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Thu, 24 Sep 2026 01:16:22 +0530 Subject: [PATCH 3/3] feat: ship with stubs off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production runs the shipped config.json as it is, so the bundled default decides what a deployment does. With stubs on it would answer farmers with canned advice. Now that the client speaks the Experience API contract, the default is off and chat goes to `api.baseUrl`. What changes for a developer: `npm run dev` needs an Experience API, which the dev server proxies to (DEV_API_URL, default localhost:8078). Without one, every question shows an error card. Flipping `stubs.enabled` to true locally brings the canned replies back; the flag is a runtime config value, so nothing else changes, and the stub still speaks the API's own event stream. The change must not be committed, and the docs now say so. ADR-0000 §6.1, README, CLAUDE.md and AGENTS.md are updated in this commit, as the ADR rule for a flag flip asks. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 9 ++++++--- README.md | 14 +++++++++----- docs/ADR/0000-fork-oan-ui.md | 2 +- src/config/app-config.json | 2 +- 4 files changed, 17 insertions(+), 10 deletions(-) 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/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"