Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/ADR/0000-fork-oan-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/answer-sources.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AnswerSources>[0]["sources"]) =>
renderToStaticMarkup(<AnswerSources sources={sources} />);

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("<a");
});
});
92 changes: 92 additions & 0 deletions src/__tests__/card-bubble-sources.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* @vitest-environment happy-dom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

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 };
});
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(<CardBubble message={message} />);
});

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<HTMLButtonElement>('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<HTMLButtonElement>('button[title="Copy"]')?.click();
});

expect(writeText).toHaveBeenCalledWith("Light rain after 3 pm.");
});
});
9 changes: 9 additions & 0 deletions src/__tests__/chat-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.")]);
Expand Down
74 changes: 74 additions & 0 deletions src/__tests__/chat-store-sources.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { AnswerSource } from "@/lib/api-service";
import { useLanguage } from "@/components/LanguageProvider";

type Translate = ReturnType<typeof useLanguage>["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 (
<div className="mt-3 text-sm text-muted-foreground dark:text-gray-400">
<div className="font-medium">{sourcesLabel(t, sources.length)}:</div>
<ol className="mt-1 space-y-0.5">
{sources.map((source, i) => (
<li key={source.id}>{`[${i + 1}]: ${source.name}`}</li>
))}
</ol>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -77,6 +80,8 @@ export function CardBubble({ message }: { readonly message: CardMessage }) {
<SafeMarkdown>{message.body}</SafeMarkdown>
</div>

{!message.isError && <AnswerSources sources={message.sources} />}

{/* Retry Button for error messages */}
{isRetryableError && (
<div className="mt-3 flex items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AnswerSource } from "@/lib/api-service";
import { type LanguageCode } from "../../config";

export type MessageRole = "user" | "assistant" | "system";
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/store/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions translations/as.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"Listen": "শুনক",
"selectLanguage": "ভাষা বাছনি কৰক",
"disclaimerText": "AI-উৎপন্ন তথ্য। প্ৰয়োজন হ'লে স্থানীয়ভাৱে পৰীক্ষা কৰক।",
"source": "উৎস",
"sources": "উৎসসমূহ",
"questions": [
{
"key": "schemes.info.pm_kisan",
Expand Down
2 changes: 2 additions & 0 deletions translations/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"Listen": "শুনুন",
"selectLanguage": "ভাষা নির্বাচন করুন",
"disclaimerText": "AI-উৎপন্ন তথ্য। প্রয়োজন হলে স্থানীয়ভাবে যাচাই করুন।",
"source": "উৎস",
"sources": "উৎসসমূহ",
"questions": [
{
"key": "schemes.info.pm_kisan",
Expand Down
2 changes: 2 additions & 0 deletions translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions translations/gu.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"Listen": "સાંભળો",
"selectLanguage": "ભાષા પસંદ કરો",
"disclaimerText": "AI-ઉત્પન્ન સામગ્રી. જો જરૂર હોય તો સ્થાનિક રીતે ચકાસો.",
"source": "સ્ત્રોત",
"sources": "સ્ત્રોતો",
"settings": "સેટિંગ્સ",
"aiLoaderMessages": [
"તમારો પ્રશ્ન સમજવામાં આવી રહ્યો છે...",
Expand Down
2 changes: 2 additions & 0 deletions translations/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"Listen": "सुनें",
"selectLanguage": "भाषा चुनें",
"disclaimerText": "AI-उत्पन्न जानकारी। कृपया स्वतंत्र रूप से सत्यापित करें।",
"source": "स्रोत",
"sources": "स्रोत",
"recordingListening": "[botName] सुन रही है, कृपया बोलना शुरू करें।",
"questions": [
{
Expand Down
Loading
Loading