diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..a7d98e4 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,17 @@ +# experience-ui + +The web client a farmer uses to ask the OpenAgriNet assistant a question and read its answer. + +## Language + +**Answer**: +The assistant's finished reply to one question, shown once it has fully arrived. +_Avoid_: Response, reply, message + +**Source**: +Something an Answer drew on, such as a publication or an agency, known by its name. An Answer can have none or several. +_Avoid_: Reference, credit, origin + +**Citation**: +A stretch of an Answer's text that points at one Source. +_Avoid_: Footnote, annotation diff --git a/docs/ADR/0000-fork-oan-ui.md b/docs/ADR/0000-fork-oan-ui.md index be73112..dd97e36 100644 --- a/docs/ADR/0000-fork-oan-ui.md +++ b/docs/ADR/0000-fork-oan-ui.md @@ -134,6 +134,7 @@ is doing right now: |---|---| | **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"` | +| **Answer sources** | Under each answer, the sources it drew on, numbered from 1 by name. Copy includes them. Not shown under a failed answer | | **Copy an answer** | Client-side | | **Retry a failed answer** | Client-side | | **Light and dark theme** | Client-side | diff --git a/src/__tests__/answer-sources.test.tsx b/src/__tests__/answer-sources.test.tsx new file mode 100644 index 0000000..cf9c432 --- /dev/null +++ b/src/__tests__/answer-sources.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/LanguageProvider", () => ({ + useLanguage: () => ({ language: "en", t: (key: string) => ({ source: "Source", sources: "Sources" })[key] ?? key }) +})); + +import { + AnswerSources, + formatSources +} from "@/components/screens-component/chat-screen/components/bubbles/answer-sources"; + +/** + * Below an answer the user sees where it came from, numbered from 1 in the + * order the API sent them. Copy writes the same list, so a pasted answer + * keeps its sources. + */ +const IMD = { id: "src_1", name: "IMD", url: "https://mausam.imd.gov.in/" }; +const MPKV = { id: "src_7", name: "Package of Practices, MPKV Rahuri" }; + +describe("formatSources", () => { + it("is empty when there are no sources", () => { + expect(formatSources([], "Sources")).toBe(""); + }); + + it("numbers each source from 1 under the label", () => { + expect(formatSources([IMD, MPKV], "Sources")).toBe("Sources:\n[1]: IMD\n[2]: Package of Practices, MPKV Rahuri"); + }); +}); + +describe("AnswerSources", () => { + const render = (sources: Parameters[0]["sources"]) => + renderToStaticMarkup(); + + it("renders nothing without sources", () => { + expect(render([])).toBe(""); + expect(render(undefined)).toBe(""); + }); + + it("says Source for one and Sources for more", () => { + expect(render([IMD])).toContain("Source:"); + expect(render([IMD, MPKV])).toContain("Sources:"); + }); + + it("shows each name by its number, without the url", () => { + const html = render([IMD, MPKV]); + + expect(html).toContain("[1]: IMD"); + expect(html).toContain("[2]: Package of Practices, MPKV Rahuri"); + expect(html).not.toContain("mausam.imd.gov.in"); + expect(html).not.toContain(" { + const { default: shipped } = await import("@/config/app-config.json"); + const config = { ...shipped, stubs: { enabled: false }, features: {} }; + return { getConfig: () => config }; +}); +vi.mock("@/components/LanguageProvider", () => ({ + useLanguage: () => ({ language: "en", t: (key: string) => ({ source: "Source", sources: "Sources" })[key] ?? key }) +})); + +import { CardBubble } from "@/components/screens-component/chat-screen/components/bubbles/card-bubble"; +import type { CardMessage } from "@/components/screens-component/chat-screen/components/bubbles/chat-types"; + +/** + * The card is where sources reach the user: under an answer, never under an + * error, and in what Copy puts on the clipboard. + */ +const answered: CardMessage = { + id: "a-1", + role: "assistant", + type: "card", + createdAt: "2026-09-28T10:00:00.000Z", + body: "Light rain after 3 pm.", + showListenRow: true, + sources: [{ id: "src_1", name: "IMD", url: "https://mausam.imd.gov.in/" }] +}; + +let container: HTMLDivElement; +let root: Root; + +const render = (message: CardMessage) => + act(() => { + root.render(); + }); + +describe("card bubble sources", () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("shows the sources under an answered card", async () => { + await render(answered); + + expect(container.textContent).toContain("Source:"); + expect(container.textContent).toContain("[1]: IMD"); + }); + + it("shows none under an error card", async () => { + await render({ ...answered, body: "Sorry, something went wrong.", isError: true, failedUserText: "Weather?" }); + + expect(container.textContent).not.toContain("[1]: IMD"); + }); + + it("copies the answer followed by its sources", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } }); + await render(answered); + + await act(async () => { + container.querySelector('button[title="Copy"]')?.click(); + }); + + expect(writeText).toHaveBeenCalledWith("Light rain after 3 pm.\n\nSource:\n[1]: IMD"); + }); + + it("copies only the answer when it has no sources", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } }); + await render({ ...answered, sources: [] }); + + await act(async () => { + container.querySelector('button[title="Copy"]')?.click(); + }); + + expect(writeText).toHaveBeenCalledWith("Light rain after 3 pm."); + }); +}); diff --git a/src/__tests__/chat-history.test.ts b/src/__tests__/chat-history.test.ts index 7aba5d8..25e7767 100644 --- a/src/__tests__/chat-history.test.ts +++ b/src/__tests__/chat-history.test.ts @@ -64,6 +64,15 @@ describe("buildHistory", () => { expect(history.map((h) => h.text)).toEqual(["Tell me a joke", "I can help with farming questions only.", "Prices?", "Which district?"]); }); + it("sends only the answer text, never its sources", () => { + const history = buildHistory([user("Weather tomorrow?"), card("Light rain.", { sources: [{ id: "src_1", name: "IMD" }] })]); + + expect(history).toEqual([ + { role: "user", text: "Weather tomorrow?" }, + { role: "assistant", text: "Light rain." } + ]); + }); + it("skips image messages and a question with no answer between two questions", () => { const image: ChatMessage = { id: "img", role: "user", type: "image", imageUrl: "blob:x", createdAt: at() }; const history = buildHistory([image, card("Looks like leaf rust."), user("Lost question"), user("Weather today?"), card("Clear.")]); diff --git a/src/__tests__/chat-store-sources.test.ts b/src/__tests__/chat-store-sources.test.ts new file mode 100644 index 0000000..399f4d6 --- /dev/null +++ b/src/__tests__/chat-store-sources.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The shipped config, with every optional surface off and the real API path. +vi.mock("@/lib/config/runtime-config", async () => { + const { default: shipped } = await import("@/config/app-config.json"); + const config = { ...shipped, stubs: { enabled: false }, features: {} }; + return { getConfig: () => config }; +}); + +import apiService, { ApiError, type FinalAnswer } from "@/lib/api-service"; +import { useChatStore } from "@/hooks/store/chat"; +import type { CardMessage } from "@/components/screens-component/chat-screen/components/bubbles/chat-types"; + +/** + * The sources are what an answer drew on. They belong on an answered card + * and nowhere else: a failed turn has no answer to credit. + */ +const answer = (extra: Partial = {}): FinalAnswer => ({ + sessionId: "s-1", + messageId: "m-1", + assistantMessageId: "a-1", + traceId: "t-1", + outcome: { status: "answered", cause: null }, + content: [{ type: "text", text: "Light rain after 3 pm.", citations: [] }], + sources: [{ id: "src_1", name: "IMD", url: "https://mausam.imd.gov.in/" }], + ...extra +}); + +const lastCard = (): CardMessage => { + const card = useChatStore.getState().messages.at(-1); + if (card?.type !== "card") throw new Error("The last message is not a card"); + return card; +}; + +describe("chat store sources", () => { + beforeEach(() => { + useChatStore.getState().clearChat(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps the sources on an answered card", async () => { + vi.spyOn(apiService, "sendUserQuery").mockResolvedValue(answer()); + + await useChatStore.getState().sendText("Weather tomorrow?", "en"); + + expect(lastCard().sources).toEqual([{ id: "src_1", name: "IMD", url: "https://mausam.imd.gov.in/" }]); + }); + + it("leaves them off a turn that completed with an error", async () => { + vi.spyOn(apiService, "sendUserQuery").mockResolvedValue( + answer({ error: { code: "provider_unavailable", message: "down", retryable: true } }) + ); + + await useChatStore.getState().sendText("Weather tomorrow?", "en"); + + expect(lastCard().isError).toBe(true); + expect(lastCard().sources).toBeUndefined(); + }); + + it("leaves them off a turn that failed outright", async () => { + vi.spyOn(apiService, "sendUserQuery").mockRejectedValue( + new ApiError({ code: "upstream_error", message: "broken", retryable: true }) + ); + + await useChatStore.getState().sendText("Weather tomorrow?", "en"); + + expect(lastCard().isError).toBe(true); + expect(lastCard().sources).toBeUndefined(); + }); +}); diff --git a/src/components/screens-component/chat-screen/components/bubbles/answer-sources.tsx b/src/components/screens-component/chat-screen/components/bubbles/answer-sources.tsx new file mode 100644 index 0000000..28a134b --- /dev/null +++ b/src/components/screens-component/chat-screen/components/bubbles/answer-sources.tsx @@ -0,0 +1,32 @@ +import type { AnswerSource } from "@/lib/api-service"; +import { useLanguage } from "@/components/LanguageProvider"; + +type Translate = ReturnType["t"]; + +/** "Source" for one, "Sources" for more, in the user's language. */ +export function sourcesLabel(t: Translate, count: number): string { + return String(t(count === 1 ? "source" : "sources")); +} + +/** The list as plain text, as Copy writes it. Empty when there is nothing to credit. */ +export function formatSources(sources: AnswerSource[], label: string): string { + if (sources.length === 0) return ""; + return [`${label}:`, ...sources.map((source, i) => `[${i + 1}]: ${source.name}`)].join("\n"); +} + +/** Where the answer came from, numbered from 1 in the order the API sent them. */ +export function AnswerSources({ sources }: { readonly sources?: AnswerSource[] }) { + const { t } = useLanguage(); + if (!sources?.length) return null; + + return ( +
+
{sourcesLabel(t, sources.length)}:
+
    + {sources.map((source, i) => ( +
  1. {`[${i + 1}]: ${source.name}`}
  2. + ))} +
+
+ ); +} diff --git a/src/components/screens-component/chat-screen/components/bubbles/card-bubble.tsx b/src/components/screens-component/chat-screen/components/bubbles/card-bubble.tsx index 5c6174b..4e12633 100644 --- a/src/components/screens-component/chat-screen/components/bubbles/card-bubble.tsx +++ b/src/components/screens-component/chat-screen/components/bubbles/card-bubble.tsx @@ -8,9 +8,10 @@ import { FEATURES } from "@/lib/config/features"; import { useLanguage } from "@/components/LanguageProvider"; import { cn } from "@/lib/utils"; import { SafeMarkdown } from "./safe-markdown"; +import { AnswerSources, formatSources, sourcesLabel } from "./answer-sources"; export function CardBubble({ message }: { readonly message: CardMessage }) { - const { language } = useLanguage(); + const { language, t } = useLanguage(); const playTTS = useChatStore((s) => s.playTTS); const pauseTTS = useChatStore((s) => s.pauseTTS); const resumeTTS = useChatStore((s) => s.resumeTTS); @@ -48,7 +49,9 @@ export function CardBubble({ message }: { readonly message: CardMessage }) { const handleCopy = async () => { try { - await navigator.clipboard.writeText(message.body); + const sources = message.isError ? [] : (message.sources ?? []); + const credits = formatSources(sources, sourcesLabel(t, sources.length)); + await navigator.clipboard.writeText(credits ? `${message.body}\n\n${credits}` : message.body); setShowCopySuccess(true); setTimeout(() => setShowCopySuccess(false), 1000); } catch (error) { @@ -77,6 +80,8 @@ export function CardBubble({ message }: { readonly message: CardMessage }) { {message.body} + {!message.isError && } + {/* Retry Button for error messages */} {isRetryableError && (
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 95e9c59..0699bf7 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 @@ -1,3 +1,4 @@ +import type { AnswerSource } from "@/lib/api-service"; import { type LanguageCode } from "../../config"; export type MessageRole = "user" | "assistant" | "system"; @@ -25,6 +26,8 @@ export type CardMessage = MessageBase & { traceId?: string; title?: string; body: string; + /** What the answer drew on, in the API's order. Set only on an answered card. */ + sources?: AnswerSource[]; actions?: { id: string; label: string }[]; showListenRow?: boolean; isError?: boolean; diff --git a/src/hooks/store/chat/index.ts b/src/hooks/store/chat/index.ts index 7ff9762..2e9f28a 100644 --- a/src/hooks/store/chat/index.ts +++ b/src/hooks/store/chat/index.ts @@ -287,7 +287,7 @@ async function streamTurn( return false; } - draw({ body: text, showListenRow: true }); + draw({ body: text, showListenRow: true, sources: answer.sources }); set({ isInputLocked: false }); return true; } catch (error) { diff --git a/translations/as.json b/translations/as.json index e6e7d6a..afdcd65 100644 --- a/translations/as.json +++ b/translations/as.json @@ -8,6 +8,8 @@ "Listen": "শুনক", "selectLanguage": "ভাষা বাছনি কৰক", "disclaimerText": "AI-উৎপন্ন তথ্য। প্ৰয়োজন হ'লে স্থানীয়ভাৱে পৰীক্ষা কৰক।", + "source": "উৎস", + "sources": "উৎসসমূহ", "questions": [ { "key": "schemes.info.pm_kisan", diff --git a/translations/bn.json b/translations/bn.json index 99f30f8..4d2f125 100644 --- a/translations/bn.json +++ b/translations/bn.json @@ -8,6 +8,8 @@ "Listen": "শুনুন", "selectLanguage": "ভাষা নির্বাচন করুন", "disclaimerText": "AI-উৎপন্ন তথ্য। প্রয়োজন হলে স্থানীয়ভাবে যাচাই করুন।", + "source": "উৎস", + "sources": "উৎসসমূহ", "questions": [ { "key": "schemes.info.pm_kisan", diff --git a/translations/en.json b/translations/en.json index dea65ee..a52a784 100644 --- a/translations/en.json +++ b/translations/en.json @@ -8,6 +8,8 @@ "Listen": "Listen", "selectLanguage": "Select language", "disclaimerText": "AI-generated content. Please verify locally if required.", + "source": "Source", + "sources": "Sources", "questions": [ { "key": "schemes.info.pm_kisan", diff --git a/translations/gu.json b/translations/gu.json index b2cefbe..835b4ac 100644 --- a/translations/gu.json +++ b/translations/gu.json @@ -8,6 +8,8 @@ "Listen": "સાંભળો", "selectLanguage": "ભાષા પસંદ કરો", "disclaimerText": "AI-ઉત્પન્ન સામગ્રી. જો જરૂર હોય તો સ્થાનિક રીતે ચકાસો.", + "source": "સ્ત્રોત", + "sources": "સ્ત્રોતો", "settings": "સેટિંગ્સ", "aiLoaderMessages": [ "તમારો પ્રશ્ન સમજવામાં આવી રહ્યો છે...", diff --git a/translations/hi.json b/translations/hi.json index e739040..deb5928 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -10,6 +10,8 @@ "Listen": "सुनें", "selectLanguage": "भाषा चुनें", "disclaimerText": "AI-उत्पन्न जानकारी। कृपया स्वतंत्र रूप से सत्यापित करें।", + "source": "स्रोत", + "sources": "स्रोत", "recordingListening": "[botName] सुन रही है, कृपया बोलना शुरू करें।", "questions": [ { diff --git a/translations/kn.json b/translations/kn.json index 2a5093f..662681d 100644 --- a/translations/kn.json +++ b/translations/kn.json @@ -8,6 +8,8 @@ "Listen": "ಕೇಳಿ", "selectLanguage": "ಭಾಷೆ ಆಯ್ಕೆಮಾಡಿ", "disclaimerText": "AI-ಉತ್ಪಾದಿತ ಮಾಹಿತಿ. ಅಗತ್ಯವಿದ್ದರೆ ಸ್ಥಳೀಯವಾಗಿ ಪರಿಶೀಲಿಸಿ.", + "source": "ಮೂಲ", + "sources": "ಮೂಲಗಳು", "questions": [ { "key": "schemes.info.pm_kisan", diff --git a/translations/ml.json b/translations/ml.json index 8cde14a..fb75fd5 100644 --- a/translations/ml.json +++ b/translations/ml.json @@ -8,6 +8,8 @@ "Listen": "കേൾക്കുക", "selectLanguage": "ഭാഷ തിരഞ്ഞെടുക്കുക", "disclaimerText": "AI-ഉത്‌പാദിത ഉള്ളടക്കം. ആവശ്യമെങ്കിൽ പ്രാദേശികമായി പരിശോധിക്കുക.", + "source": "ഉറവിടം", + "sources": "ഉറവിടങ്ങൾ", "questions": [ { "key": "schemes.info.pm_kisan", diff --git a/translations/mr.json b/translations/mr.json index e3515c3..261c7ab 100644 --- a/translations/mr.json +++ b/translations/mr.json @@ -10,6 +10,8 @@ "recordingListening": "[botName] ऐकत आहे, बोलायला सुरुवात करा", "selectLanguage": "भाषा निवडा", "disclaimerText": "AI-निर्मित माहिती. कृपया स्वतंत्रपणे सत्यापित करा।", + "source": "स्रोत", + "sources": "स्रोत", "questions": [ { "key": "schemes.info.pm_kisan", diff --git a/translations/ta.json b/translations/ta.json index 3a58b48..0749c0e 100644 --- a/translations/ta.json +++ b/translations/ta.json @@ -8,6 +8,8 @@ "Listen": "கேளுங்கள்", "selectLanguage": "மொழி தேர்ந்தெடு", "disclaimerText": "AI-ஆல் உருவாக்கப்பட்ட உள்ளடக்கம். தேவைப்பட்டால் உள்ளூரில் சரிபார்க்கவும்.", + "source": "ஆதாரம்", + "sources": "ஆதாரங்கள்", "questions": [ { "key": "schemes.info.pm_kisan", diff --git a/translations/te.json b/translations/te.json index 0ec5312..ad73799 100644 --- a/translations/te.json +++ b/translations/te.json @@ -8,6 +8,8 @@ "Listen": "వినండి", "selectLanguage": "భాష ఎంచుకోండి", "disclaimerText": "AI-ఉత్పత్తి సమాచారం. అవసరమైతే స్థానికంగా ధృవీకరించండి.", + "source": "మూలం", + "sources": "మూలాలు", "questions": [ { "key": "schemes.info.pm_kisan",