diff --git a/.env.example b/.env.example index c2fc063..cc12a10 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,9 @@ ENABLE_GENUI=1 # Optional private local diagnostics with bounded retention. # RAG_TRACE_DIR=data/app/traces + + +# Optional LangSmith dashboard. Traces include questions, evidence and model outputs. +LANGSMITH_API_KEY= +LANGSMITH_PROJECT=techqa-rag +LANGSMITH_TRACING=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64a86b0..3eabb0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: timeout-minutes: 15 env: RAGAS_DO_NOT_TRACK: 'true' + LANGSMITH_TRACING: 'false' HF_HUB_OFFLINE: '1' GROQ_API_KEY: '' GROK_API_KEY: '' diff --git a/.gitignore b/.gitignore index 6808174..59613b8 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,18 @@ !/tests/test_v1_acceptance.py !/frontend/src/lib/request-origin.ts !/evaluations/results/v1_acceptance.json + + +!/rag_app/tracing.py +!/tests/test_tracing.py + +!/frontend/src/app/fonts/ +!/frontend/src/app/fonts/*.woff2 +!/frontend/src/app/fonts/OFL.txt + +!/tests/conftest.py + +!/scripts/publish_langsmith.py +!/tests/test_langsmith_export.py + +!/evaluations/results/observability_v1.json diff --git a/README.md b/README.md index 81c55d9..cf79e02 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,28 @@ Question → Gemma embedding → Qdrant exact search → BGE reranking - **Sessions:** recent conversation plus a summary, current-session memory, exact answer caching and an explicit regeneration bypass. - **Evaluation:** an offline RAGAS runner scores saved evidence and released answers. Judge decisions and local query embeddings are cached; withheld responses and evaluation errors stay visible. +## Inspect a run in LangSmith + +Add `LANGSMITH_API_KEY` to the server's `.env`. The default project is `techqa-rag`. Set `LANGSMITH_TRACING=false` to disable export. Traces contain questions, retrieved passages, private drafts and verification decisions, so keep this project private. + +Each question has a parent trace with child steps for query embedding, exact search, reranking, context selection and model calls. Inspect timings, actual token usage, cache hits, source passages, repair attempts and the released outcome. The answer workspace links to its trace. Local diagnostic logs continue to work when tracing is disabled or export fails. + +The workspace streams actual activity and retrieved excerpts. Verified answers and checked OpenUI layouts appear after their release checks. The run history remains available after completion. Desktop uses a compact navigation rail, while smaller screens stack the answer and evidence. + +For developers and coding agents, `/ask` remains a server-sent event endpoint. `stage` events describe activity, `evidence` carries retrieved `sources`, `final` carries the approved outcome and `error` carries a safe error category/message. `done` closes the run. Final statuses are `answered`, `needs_clarification` and `insufficient_evidence`. Existing exact-cache, session, cancellation and regeneration behavior remains covered by tests. + +Collect a fixed random training sample with a hard HTTP-attempt cap. Quota failures stop collection and are retried when the same unchanged run resumes. Completed cases are saved after each question, with a `completed.json` snapshot for inspection. + +```bash +python -m scripts.eval_ragas collect --limit 12 --sample-seed 20261110 \ + --max-requests 18 --output data/ragas/review +python -m scripts.publish_langsmith --cases data/ragas/review/completed.json +``` + +Publishing replays saved outputs into a LangSmith experiment with response status, selected-reference-article presence and citation-format checks. It makes **zero new model calls**. Add `--ragas path/to/matching-report.json` to publish existing semantic scores too. RAGAS judging remains a separate, explicitly budgeted command below. Reviewed or failed judge results retain their flags. + +The [November 10 observability batch](evaluations/results/observability_v1.json) selected **12 training questions**. Retrieval selected the labeled article for **11/12**. **3 answers** were released and passed citation-format checks. **9 provider quota failures** prevented completion. The run used **15 HTTP attempts and 33,230 reported tokens**. This batch establishes dashboard visibility and exposes a quota-handling issue, which is now covered by a stop-on-quota regression test. New semantic RAGAS scores await available provider quota. + ## Evaluation suite ![Evaluation layers: components, pipeline, application, regression and planned monitoring](evaluations/overview.svg) diff --git a/evaluations/results/observability_v1.json b/evaluations/results/observability_v1.json new file mode 100644 index 0000000..5abd74c --- /dev/null +++ b/evaluations/results/observability_v1.json @@ -0,0 +1,207 @@ +{ + "schema": "saved_case_checks_v1", + "cohort_id": "44dd958d19897a7639d9a2cb420e53270b2bea37328d2ee42a9cd7d124e9f97c", + "case_count": 12, + "outcomes": { + "answered": 3, + "generation_error": 9 + }, + "metrics": { + "answer_coverage": 0.25, + "selected_gold_document_hit_rate": 0.9166666666666666, + "citation_contract_pass_rate": 1.0 + }, + "denominators": { + "selected_gold_document_hit_rate": 12, + "citation_contract_pass_rate": 3 + }, + "operations": { + "generation_seconds": [ + 53.6789352909982, + 89.72582383299596, + 94.20225566600857, + 46.86580899999535, + 2.3957592910010135, + 2.1422123329975875, + 2.1818811670091236, + 2.2330157079995843, + 2.2595714589988347, + 2.226248499995563, + 1.9820034590084106, + 0.29575791700335685 + ], + "generation_reported_tokens": 33230, + "generation_usage_cases": 12, + "cost_usd": null + }, + "cases": [ + { + "question_id": "TRAIN_Q252", + "outcome": "answered", + "selected_gold_document_hit": 1, + "selected_passage_count": 7, + "citation_contract": { + "state": "checked", + "pass": true, + "reason": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q425", + "outcome": "answered", + "selected_gold_document_hit": 1, + "selected_passage_count": 9, + "citation_contract": { + "state": "checked", + "pass": true, + "reason": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q376", + "outcome": "answered", + "selected_gold_document_hit": 1, + "selected_passage_count": 14, + "citation_contract": { + "state": "checked", + "pass": true, + "reason": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q013", + "outcome": "generation_error", + "selected_gold_document_hit": 1, + "selected_passage_count": 8, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q414", + "outcome": "generation_error", + "selected_gold_document_hit": 1, + "selected_passage_count": 10, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q488", + "outcome": "generation_error", + "selected_gold_document_hit": 1, + "selected_passage_count": 10, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q361", + "outcome": "generation_error", + "selected_gold_document_hit": 1, + "selected_passage_count": 12, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q117", + "outcome": "generation_error", + "selected_gold_document_hit": 1, + "selected_passage_count": 14, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q548", + "outcome": "generation_error", + "selected_gold_document_hit": 1, + "selected_passage_count": 8, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q310", + "outcome": "generation_error", + "selected_gold_document_hit": 1, + "selected_passage_count": 8, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q230", + "outcome": "generation_error", + "selected_gold_document_hit": 0, + "selected_passage_count": 8, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + }, + { + "question_id": "TRAIN_Q353", + "outcome": "generation_error", + "selected_gold_document_hit": 1, + "selected_passage_count": 8, + "citation_contract": { + "state": "skipped", + "pass": null + }, + "ragas": {}, + "judge_integrity_flags": [] + } + ], + "limits": [ + "Selected-document hit is not search Recall@K or semantic evidence recall.", + "Citation validity does not establish claim support.", + "Version flags are heuristics for review; raw scores are never adjusted.", + "No style, toxicity/PII, live feedback or drift scores are computed." + ], + "scope": "Fixed seeded random answerable training tuning sample; standalone canonical pipeline; no memory or generated layout evaluation.", + "selection": { + "seed": 20261110, + "planned": 12, + "attempted": 12 + }, + "provider_http_attempts": 15, + "limitations": [ + "Nine provider quota failures are infrastructure errors, not answer-quality failures.", + "Three released answers passed citation syntax/source-ID checks. Semantic correctness was not measured in this batch.", + "Selected gold article presence is not semantic context recall.", + "No new RAGAS judge calls.", + "Collector was fixed after this run to stop on the first quota failure.", + "No reserved development questions were used." + ] +} diff --git a/frontend/src/app/fonts/OFL.txt b/frontend/src/app/fonts/OFL.txt new file mode 100644 index 0000000..8d003fe --- /dev/null +++ b/frontend/src/app/fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2023 Vercel, in collaboration with basement.studio + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/app/fonts/geist-latin.woff2 b/frontend/src/app/fonts/geist-latin.woff2 new file mode 100644 index 0000000..445e0e5 Binary files /dev/null and b/frontend/src/app/fonts/geist-latin.woff2 differ diff --git a/frontend/src/app/fonts/geist-mono-latin.woff2 b/frontend/src/app/fonts/geist-mono-latin.woff2 new file mode 100644 index 0000000..68eeb7f Binary files /dev/null and b/frontend/src/app/fonts/geist-mono-latin.woff2 differ diff --git a/frontend/src/app/genui.css b/frontend/src/app/genui.css index ac87edd..2eaad54 100644 --- a/frontend/src/app/genui.css +++ b/frontend/src/app/genui.css @@ -11,7 +11,7 @@ .gen-answer-card h3, .gen-procedure h3, .gen-configuration h3, .gen-notice h3 { margin: 0; color: #18212b; font-size: 15px; font-weight: 620; line-height: 1.4; } .gen-answer-card p, .gen-procedure li p, .gen-notice p { margin: 7px 0 0; color: #404b57; font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; } .gen-source-markers { display: inline-flex; flex-wrap: wrap; gap: 4px; margin-left: 7px; vertical-align: baseline; } -.gen-source-markers a { color: #24588c; font-size: 10px; font-weight: 600; text-decoration: none; white-space: nowrap; } +.gen-source-markers a { color: #24588c; font-size: 11px; font-weight: 600; text-decoration: none; white-space: nowrap; } .gen-source-markers a:hover { text-decoration: underline; text-underline-offset: 2px; } .gen-procedure { padding: 16px 18px; } .gen-procedure > header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } @@ -31,10 +31,10 @@ .gen-config-row dt small { display: block; margin-top: 3px; color: #77818d; font-size: 11px; font-weight: 400; } .gen-config-row dd { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 10px; margin: 0; } .gen-config-row code { min-width: 0; color: #222d39; font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; white-space: pre-wrap; } -.gen-copy { flex: 0 0 auto; padding: 3px 7px; border: 1px solid #dce2e8; border-radius: 5px; background: #fff; color: #52606e; font-size: 10px; line-height: 1.5; } +.gen-copy { flex: 0 0 auto; padding: 3px 7px; border: 1px solid #dce2e8; border-radius: 5px; background: #fff; color: #52606e; font-size: 11px; line-height: 1.5; } .gen-copy:hover { border-color: #aabbd0; color: #174f9c; } .gen-code { min-width: 0; margin: 0; overflow: hidden; border-radius: 7px; background: #172330; color: #edf3f8; } -.gen-code figcaption { display: flex; min-height: 33px; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 10px; background: #202e3c; color: #b7c4d0; font-family: var(--mono); font-size: 10px; } +.gen-code figcaption { display: flex; min-height: 33px; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 10px; background: #202e3c; color: #b7c4d0; font-family: var(--mono); font-size: 11px; } .gen-code pre { max-width: 100%; margin: 0; padding: 12px; overflow: auto; color: inherit; background: transparent; font-size: 12px; line-height: 1.65; tab-size: 2; } .gen-code code { color: inherit; font-family: var(--mono); white-space: pre; } .gen-code .gen-copy { border-color: #465564; background: #283746; color: #d6e0e8; } @@ -51,7 +51,7 @@ .gen-table td { color: #34404c; font-size: 12px; overflow-wrap: anywhere; } .gen-excerpt { margin: 0; padding: 14px 17px; border-left: 3px solid #d5e3f5; } .gen-excerpt p { margin: 0; color: #4d5863; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; } -.gen-excerpt cite { display: block; margin-top: 8px; color: #7e8893; font-size: 10px; font-style: normal; } +.gen-excerpt cite { display: block; margin-top: 8px; color: #7e8893; font-size: 11px; font-style: normal; } .gen-tab-list { display: flex; gap: 4px; padding: 7px 8px 0; overflow-x: auto; border-bottom: 1px solid #e7ebef; } .gen-tab-list [role="tab"] { flex: 0 0 auto; min-height: 36px; padding: 0 11px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: #697584; font-size: 12px; } .gen-tab-list [role="tab"][aria-selected="true"] { border-bottom-color: #1d5fbf; color: #174f9c; font-weight: 620; } @@ -83,3 +83,6 @@ .gen-stack-row > * { flex: 1 1 0; min-width: 0; } @media (max-width: 700px) { .gen-stack-row { flex-direction: column; } .gen-stack-row > * { width: 100%; } .gen-answer-card { padding: 18px; } .gen-answer-card h3 { font-size: 20px; } } .gen-review { min-height: 28px; } +.gen-answer-card { background: #fafafa; border-color: #e6e6e6; border-left-color: #1d5fbf; } +.gen-answer-card h3, .gen-procedure h3, .gen-configuration h3, .gen-notice h3 { font-weight: 500; } +.gen-answer-card p, .gen-procedure li p, .gen-notice p { color: #333; font-size: 14px; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ec62640..180dd3a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -13,8 +13,8 @@ --green: #167345; --red: #a83232; --radius: 8px; - --body: "Geist", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --mono: "Geist Mono", "SFMono-Regular", Consolas, monospace; + --body: var(--font-geist), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --mono: var(--font-geist-mono), "SFMono-Regular", Consolas, monospace; } *, *::before, *::after { box-sizing: border-box; } @@ -48,7 +48,7 @@ button:focus-visible, a:focus-visible, textarea:focus-visible, summary:focus-vis .search-glyph::after { position: absolute; top: 9px; left: 9px; width: 5px; height: 1.4px; transform: rotate(45deg); transform-origin: left center; background: currentColor; content: ""; } .docs-glyph::before { position: absolute; inset: 1px 2px 1px 3px; border: 1.3px solid currentColor; border-radius: 2px; content: ""; } .docs-glyph::after { position: absolute; top: 5px; left: 6px; width: 6px; height: 1px; background: currentColor; box-shadow: 0 3px 0 currentColor; content: ""; } -.nav-count { margin-left: auto; color: #858e98; font-family: var(--mono); font-size: 10px; font-variant-numeric: tabular-nums; } +.nav-count { margin-left: auto; color: #858e98; font-family: var(--mono); font-size: 11px; font-variant-numeric: tabular-nums; } .sidebar-bottom { display: flex; align-items: center; gap: 9px; margin-top: auto; padding: 12px 10px 7px; } .sidebar-bottom div { display: grid; gap: 1px; } .service-dot, .status-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: #a7adb5; } @@ -62,7 +62,7 @@ button:focus-visible, a:focus-visible, textarea:focus-visible, summary:focus-vis .genui-status { display: flex; align-items: center; gap: 7px; color: #6e7884; font-size: 11px; } .genui-status .status-dot { width: 6px; height: 6px; flex-basis: 6px; } .topbar-actions { display: flex; align-items: center; gap: 18px; } -.new-session-button { min-height: 29px; padding: 0 9px; border: 1px solid #d9dee4; border-radius: 6px; background: #fff; color: #414b56; font-size: 10px; transition: background 120ms ease, border-color 120ms ease; } +.new-session-button { min-height: 29px; padding: 0 9px; border: 1px solid #d9dee4; border-radius: 6px; background: #fff; color: #414b56; font-size: 11px; transition: background 120ms ease, border-color 120ms ease; } .new-session-button:hover:not(:disabled) { border-color: #b9c3ce; background: #fafbfc; } .new-session-button:disabled { cursor: not-allowed; opacity: .55; } .content-layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; align-items: start; gap: 42px; max-width: 1450px; margin: 0 auto; padding: 29px 34px 50px; } @@ -74,11 +74,6 @@ button:focus-visible, a:focus-visible, textarea:focus-visible, summary:focus-vis .private-mark span { display: grid; width: 17px; height: 17px; place-items: center; border: 1px solid #c8cfd7; border-radius: 50%; color: #536070; font-size: 12px; } .empty-state { display: grid; min-height: 290px; align-content: center; justify-items: center; padding: 30px 10px 40px; text-align: center; } -.empty-orbit { position: relative; display: flex; width: 50px; height: 50px; align-items: center; justify-content: center; gap: 4px; margin-bottom: 18px; border: 1px solid #d3dae2; border-radius: 50%; background: #f7f8fa; } -.empty-orbit::before, .empty-orbit::after { position: absolute; width: 1px; height: 19px; background: #d7dde4; content: ""; } -.empty-orbit::after { transform: rotate(90deg); } -.empty-orbit span { z-index: 1; display: block; width: 5px; height: 5px; border: 1px solid #5174a1; border-radius: 50%; background: white; } -.empty-orbit span:nth-child(2) { transform: translateY(-9px); }.empty-orbit span:nth-child(3) { transform: translateY(9px); } .empty-state h2 { margin: 0; font-size: 19px; font-weight: 520; letter-spacing: -.03em; } .empty-state p { margin: 7px 0 0; color: #7a838d; font-size: 13px; } @@ -86,23 +81,16 @@ button:focus-visible, a:focus-visible, textarea:focus-visible, summary:focus-vis .composer:focus-within { border-color: #8ba8cb; box-shadow: 0 0 0 3px rgb(29 95 191 / 8%); } .composer textarea { display: block; width: 100%; min-height: 57px; max-height: 180px; resize: vertical; border: 0; outline: 0; background: transparent; color: #1a2027; font-size: 14px; line-height: 1.55; } .composer textarea::placeholder { color: #949ca6; } -.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #8a929b; font-size: 10px; } -kbd { padding: 1px 4px; border: 1px solid #e0e4e8; border-radius: 3px; background: #f8f9fa; color: #69727d; font-family: var(--body); font-size: 9px; } +.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #8a929b; font-size: 11px; } +kbd { padding: 1px 4px; border: 1px solid #e0e4e8; border-radius: 3px; background: #f8f9fa; color: #69727d; font-family: var(--body); font-size: 11px; } .composer-separator { margin: 0 4px; color: #c1c6cd; } .send-button { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; border: 0; border-radius: 6px; background: #171a1e; color: #fff; font-size: 21px; line-height: 1; transition: background 120ms ease, transform 120ms ease; } .send-button:hover:not(:disabled) { transform: translateY(-1px); background: #303941; } .send-button:disabled { cursor: not-allowed; background: #bec3ca; } -.send-loading { width: 13px; height: 13px; border: 2px solid rgb(255 255 255 / 45%); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; } -.privacy-note { margin: 10px 2px 0; color: #8b939d; font-size: 10px; text-align: center; } +.privacy-note { margin: 10px 2px 0; color: #8b939d; font-size: 11px; text-align: center; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; clip-path: inset(50%); } .progress-panel, .error-panel, .nonanswer-panel { margin: 26px 0; padding: 17px 18px; border: 1px solid #dfe4ea; border-radius: var(--radius); background: white; } -.progress-heading { display: flex; align-items: center; gap: 12px; } -.progress-spinner { width: 16px; height: 16px; border: 2px solid #d4deea; border-top-color: #1d5fbf; border-radius: 50%; animation: spin .85s linear infinite; } -.progress-heading div { display: grid; gap: 2px; }.progress-heading strong { font-size: 13px; font-weight: 560; }.progress-heading span:not(.progress-spinner) { color: #78818c; font-size: 11px; } -.stage-list { display: flex; flex-wrap: wrap; gap: 8px 17px; margin: 17px 0 0 28px; padding: 0; list-style: none; } -.stage-list li { display: flex; align-items: center; gap: 5px; color: #8a929b; font-size: 11px; } -.stage-list li.active { color: #245a9e; }.stage-list li.complete { color: #4e6e5b; }.stage-mark { font-size: 13px; } .text-button { border: 0; background: none; color: #69737e; font-size: 11px; text-decoration: underline; text-underline-offset: 3px; } .stop-button { margin: 14px 0 0 27px; }.text-button:hover { color: var(--text); } .error-panel { display: flex; align-items: flex-start; gap: 10px; border-color: #eadcda; background: #fffdfd; } @@ -111,11 +99,11 @@ kbd { padding: 1px 4px; border: 1px solid #e0e4e8; border-radius: 3px; backgroun .result-view { margin: 24px 0 0; } .question-row { display: flex; align-items: flex-start; gap: 12px; padding-bottom: 17px; } -.question-mark { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 6px; background: #e4e8ed; color: #46515e; font-family: var(--mono); font-size: 10px; } +.question-mark { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 6px; background: #e4e8ed; color: #46515e; font-family: var(--mono); font-size: 11px; } .question-row h2 { max-width: 730px; margin: 1px 0 0; font-size: 16px; font-weight: 540; letter-spacing: -.018em; line-height: 1.5; } .answer-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 0; border-top: 1px solid #dfe4e9; } -.verified-badge { display: inline-flex; align-items: center; gap: 6px; color: #26704c; font-size: 11px; font-weight: 540; }.verified-badge span { display: grid; width: 15px; height: 15px; place-items: center; border: 1px solid #9dc4ab; border-radius: 50%; font-size: 9px; } -.request-id { color: #89919b; font-family: var(--mono); font-size: 10px; } +.verified-badge { display: inline-flex; align-items: center; gap: 6px; color: #26704c; font-size: 11px; font-weight: 540; }.verified-badge span { display: grid; width: 15px; height: 15px; place-items: center; border: 1px solid #9dc4ab; border-radius: 50%; font-size: 11px; } +.request-id { color: #89919b; font-family: var(--mono); font-size: 11px; } .canonical-answer { padding: 0 0 3px; color: #252b32; font-size: 14px; line-height: 1.74; } .answer-markdown > :first-child { margin-top: 0; }.answer-markdown > :last-child { margin-bottom: 0; } .answer-markdown p { margin: 0 0 13px; } @@ -133,28 +121,27 @@ kbd { padding: 1px 4px; border: 1px solid #e0e4e8; border-radius: 3px; backgroun .answer-markdown tr > :last-child { text-align: right; font-variant-numeric: tabular-nums; } .answer-markdown hr { height: 1px; margin: 18px 0; border: 0; background: #e6e9ed; } .presentation-fallback { margin: 9px 0; color: #78818c; font-size: 11px; } -.result-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 13px 0 1px; border-top: 1px solid #e2e6ea; color: #858e98; font-size: 10px; } +.result-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 13px 0 1px; border-top: 1px solid #e2e6ea; color: #858e98; font-size: 11px; } .secondary-button { display: inline-flex; min-height: 31px; align-items: center; gap: 7px; padding: 0 10px; border: 1px solid #d9dee4; border-radius: 6px; background: white; color: #424c57; font-size: 11px; transition: border-color 120ms ease, background 120ms ease; }.secondary-button:hover:not(:disabled) { border-color: #b9c3ce; background: #fafbfc; }.secondary-button:disabled { cursor: not-allowed; opacity: .55; }.secondary-button span { font-size: 15px; } .nonanswer-panel { margin: 7px 0 20px 36px; padding: 14px 15px; }.nonanswer-panel strong { font-size: 13px; font-weight: 550; }.nonanswer-panel p { margin: 5px 0 0; color: #727b86; font-size: 12px; } .sources-panel { position: sticky; top: 27px; min-width: 0; padding: 15px 0 0 18px; border-left: 1px solid #dfe4e9; } .sources-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 19px; } -.sources-heading h2 { margin: 0; font-size: 13px; font-weight: 560; }.sources-heading p { margin: 3px 0 0; color: #828b95; font-size: 10px; } -.source-count { display: grid; min-width: 21px; height: 21px; place-items: center; padding: 0 5px; border-radius: 5px; background: #e6e9ed; color: #555f6b; font-family: var(--mono); font-size: 10px; font-variant-numeric: tabular-nums; } +.sources-heading h2 { margin: 0; font-size: 13px; font-weight: 560; }.sources-heading p { margin: 3px 0 0; color: #828b95; font-size: 11px; } +.source-count { display: grid; min-width: 21px; height: 21px; place-items: center; padding: 0 5px; border-radius: 5px; background: #e6e9ed; color: #555f6b; font-family: var(--mono); font-size: 11px; font-variant-numeric: tabular-nums; } .source-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; } .source-list li { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 7px; padding: 12px 0 14px; border-top: 1px solid #e3e7eb; scroll-margin-top: 20px; } -.source-index { padding-top: 1px; color: #8a929c; font-family: var(--mono); font-size: 10px; font-variant-numeric: tabular-nums; } +.source-index { padding-top: 1px; color: #8a929c; font-family: var(--mono); font-size: 11px; font-variant-numeric: tabular-nums; } .source-details { min-width: 0; }.source-title-row { display: flex; align-items: flex-start; gap: 5px; font-size: 12px; line-height: 1.4; }.source-title-row a { color: #28394d; text-decoration: underline; text-decoration-color: #c7d1dc; text-underline-offset: 2px; }.source-title-row strong { font-weight: 540; }.source-title-row a:hover { color: #1d5fbf; }.external-mark { color: #9099a3; font-size: 11px; } -.source-location { overflow-wrap: anywhere; margin: 4px 0 0; color: #929aa4; font-family: var(--mono); font-size: 9px; line-height: 1.5; } -.source-details details { margin-top: 8px; }.source-details summary, .run-metrics summary { width: fit-content; color: #697480; cursor: pointer; font-size: 10px; list-style-position: inside; }.source-details blockquote { max-height: 210px; overflow: auto; margin: 8px 0 0; padding: 0 0 0 9px; border-left: 1px solid #cfd7e0; color: #69737e; font-size: 10px; line-height: 1.6; white-space: pre-wrap; } -.sources-empty { display: grid; justify-items: center; gap: 8px; padding: 28px 12px; color: #88919b; text-align: center; }.sources-empty span { color: #a2aab3; font-size: 21px; }.sources-empty p { max-width: 190px; margin: 0; font-size: 10px; line-height: 1.6; } -.run-metrics { margin-top: 16px; padding-top: 12px; border-top: 1px solid #e3e7eb; }.run-metrics dl { display: grid; gap: 5px; margin: 10px 0 0; }.run-metrics dl div { display: flex; justify-content: space-between; gap: 10px; color: #7b8590; font-size: 10px; }.run-metrics dt { margin: 0; }.run-metrics dd { max-width: 55%; overflow-wrap: anywhere; margin: 0; color: #46515e; font-family: var(--mono); font-size: 9px; text-align: right; } +.source-location { overflow-wrap: anywhere; margin: 4px 0 0; color: #929aa4; font-family: var(--mono); font-size: 11px; line-height: 1.5; } +.source-details details { margin-top: 8px; }.source-details summary, .run-metrics summary { width: fit-content; color: #697480; cursor: pointer; font-size: 11px; list-style-position: inside; }.source-details blockquote { max-height: 210px; overflow: auto; margin: 8px 0 0; padding: 0 0 0 9px; border-left: 1px solid #cfd7e0; color: #69737e; font-size: 11px; line-height: 1.6; white-space: pre-wrap; } +.sources-empty { display: grid; justify-items: center; gap: 8px; padding: 28px 12px; color: #88919b; text-align: center; }.sources-empty span { color: #a2aab3; font-size: 21px; }.sources-empty p { max-width: 190px; margin: 0; font-size: 11px; line-height: 1.6; } +.run-metrics { margin-top: 16px; padding-top: 12px; border-top: 1px solid #e3e7eb; }.run-metrics dl { display: grid; gap: 5px; margin: 10px 0 0; }.run-metrics dl div { display: flex; justify-content: space-between; gap: 10px; color: #7b8590; font-size: 11px; }.run-metrics dt { margin: 0; }.run-metrics dd { max-width: 55%; overflow-wrap: anywhere; margin: 0; color: #46515e; font-family: var(--mono); font-size: 11px; text-align: right; } .genui-panel { margin: 3px 0 17px; padding: 15px; border: 1px solid #e0e5ea; border-radius: var(--radius); background: white; } .gen-stack { display: flex; min-width: 0; flex-direction: column; }.gen-stack-row { flex-direction: row; align-items: flex-start; }.gen-gap-none { gap: 0; }.gen-gap-xs { gap: 5px; }.gen-gap-s { gap: 8px; }.gen-gap-m { gap: 12px; }.gen-gap-l { gap: 17px; }.gen-gap-xl { gap: 23px; } -.gen-code { min-width: 0; margin: 0; }.gen-code figcaption { color: #737d88; font-family: var(--mono); font-size: 10px; }.gen-code pre { margin: 5px 0 0; }.gen-table-wrap { max-width: 100%; overflow-x: auto; }.gen-table { margin: 0; }.gen-table caption { padding: 0 0 8px; color: #363f49; font-size: 12px; font-weight: 550; text-align: left; }.gen-table th:first-child, .gen-table td:first-child { padding-left: 0; }.gen-citation { display: inline-block; margin: 3px 4px 3px 0; color: #24588c; font-size: 11px; text-decoration: underline; text-decoration-color: #b6c8dd; text-underline-offset: 2px; }.gen-excerpt { margin: 0; }.gen-excerpt p { margin: 0; color: #4d5863; font-size: 12px; line-height: 1.65; white-space: pre-wrap; }.gen-excerpt cite { display: block; margin-top: 8px; color: #7e8893; font-size: 10px; font-style: normal; }.source-missing { color: #828a94; font-size: 11px; }.genui-error { margin: 0; color: #707a85; font-size: 11px; } +.gen-code { min-width: 0; margin: 0; }.gen-code figcaption { color: #737d88; font-family: var(--mono); font-size: 11px; }.gen-code pre { margin: 5px 0 0; }.gen-table-wrap { max-width: 100%; overflow-x: auto; }.gen-table { margin: 0; }.gen-table caption { padding: 0 0 8px; color: #363f49; font-size: 12px; font-weight: 550; text-align: left; }.gen-table th:first-child, .gen-table td:first-child { padding-left: 0; }.gen-citation { display: inline-block; margin: 3px 4px 3px 0; color: #24588c; font-size: 11px; text-decoration: underline; text-decoration-color: #b6c8dd; text-underline-offset: 2px; }.gen-excerpt { margin: 0; }.gen-excerpt p { margin: 0; color: #4d5863; font-size: 12px; line-height: 1.65; white-space: pre-wrap; }.gen-excerpt cite { display: block; margin-top: 8px; color: #7e8893; font-size: 11px; font-style: normal; }.source-missing { color: #828a94; font-size: 11px; }.genui-error { margin: 0; color: #707a85; font-size: 11px; } -@keyframes spin { to { transform: rotate(360deg); } } @media (max-width: 1120px) { .content-layout { grid-template-columns: minmax(0, 1fr); gap: 35px; } .answer-column { max-width: 880px; } @@ -165,8 +152,8 @@ kbd { padding: 1px 4px; border: 1px solid #e0e4e8; border-radius: 3px; backgroun .sidebar { position: static; width: 100%; height: 52px; flex: 0 0 52px; flex-direction: row; align-items: center; padding: 0 16px; border-bottom: 1px solid #e2e7ec; } .brand { padding: 0; font-size: 16px; }.brand-mark { width: 19px; height: 19px; }.workspace-switcher, .primary-nav, .sidebar-bottom { display: none; } .workspace-shell { display: block; }.main-column { width: 100%; min-height: calc(100vh - 52px); margin: 0; } - .topbar { height: 47px; padding: 0 17px; }.topbar-actions { gap: 10px; }.new-session-button { min-height: 28px; padding: 0 7px; font-size: 9px; }.content-layout { gap: 29px; padding: 22px 17px 34px; }.page-heading { align-items: flex-start; margin-bottom: 16px; }.page-heading h1 { font-size: 21px; }.page-heading p { max-width: 270px; font-size: 12px; line-height: 1.5; }.private-mark { margin-top: 3px; font-size: 10px; }.empty-state { min-height: 220px; padding: 20px 8px 28px; }.empty-state h2 { font-size: 17px; }.empty-state p { max-width: 280px; font-size: 12px; } - .composer { padding: 12px 11px 9px 12px; }.composer textarea { min-height: 55px; font-size: 13px; }.composer-footer { font-size: 9px; }.composer-footer kbd { font-size: 8px; }.send-button { width: 30px; height: 30px; flex-basis: 30px; } + .topbar { height: 47px; padding: 0 17px; }.topbar-actions { gap: 10px; }.new-session-button { min-height: 28px; padding: 0 7px; font-size: 11px; }.content-layout { gap: 29px; padding: 22px 17px 34px; }.page-heading { align-items: flex-start; margin-bottom: 16px; }.page-heading h1 { font-size: 21px; }.page-heading p { max-width: 270px; font-size: 12px; line-height: 1.5; }.private-mark { margin-top: 3px; font-size: 11px; }.empty-state { min-height: 220px; padding: 20px 8px 28px; }.empty-state h2 { font-size: 17px; }.empty-state p { max-width: 280px; font-size: 12px; } + .composer { padding: 12px 11px 9px 12px; }.composer textarea { min-height: 55px; font-size: 13px; }.composer-footer { font-size: 11px; }.composer-footer kbd { font-size: 11px; }.send-button { width: 30px; height: 30px; flex-basis: 30px; } .question-row h2 { font-size: 15px; }.result-view { margin-top: 18px; }.answer-meta { padding: 10px 0; }.canonical-answer { font-size: 13px; }.result-actions { align-items: flex-start; }.result-actions > span { max-width: 115px; text-align: right; }.sources-panel { grid-template-columns: 1fr; }.sources-heading { margin-bottom: 13px; }.sources-empty { padding: 10px 0; }.source-list { grid-template-columns: 1fr; }.genui-panel { padding: 11px; } } @media (max-width: 400px) { @@ -189,3 +176,65 @@ kbd { padding: 1px 4px; border: 1px solid #e0e4e8; border-radius: 3px; backgroun .genui-panel .gen-stack > :nth-child(n+8) { animation-delay: .7s; } @keyframes reveal-approved { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } } @media (prefers-reduced-motion: reduce) { .genui-panel .gen-stack > * { animation: none; } } + +/* Evidence workspace: quiet canvas, readable documents, compact live activity. */ +:root { --ground: #f8f8f8; --raised: #eee; --border: #e6e6e6; --rule: #f0f0f0; --text: #0a0a0a; --muted: #626262; --quiet: #686868; --accent: #141414; } +body { font-size: 15px; } +.sidebar { width: 208px; border-right: 1px solid var(--border); background: var(--ground); } +.main-column { width: calc(100% - 208px); margin-left: 208px; background: var(--surface); } +.topbar { height: 64px; background: var(--surface); } +.content-layout { grid-template-columns: minmax(0, 1fr) 300px; gap: 48px; padding-top: 40px; } +.page-heading h1 { font-size: 26px; font-weight: 500; letter-spacing: -.035em; } +.page-heading p, .source-details, .source-details blockquote { font-size: 14px; } +.empty-state { min-height: 360px; align-content: center; justify-items: start; text-align: left; max-width: 570px; padding: 40px 0; } +.empty-eyebrow { color: var(--muted); font-family: var(--mono); font-size: 11px; letter-spacing: .08em; margin-bottom: 20px; } +.empty-state h2 { font-size: clamp(28px, 3vw, 40px); font-weight: 500; line-height: 1.15; letter-spacing: -.045em; max-width: 440px; } +.empty-state p { max-width: 400px; font-size: 15px; line-height: 1.7; } +.composer { border-color: #d4d4d4; box-shadow: none; background: var(--surface); } +.composer textarea { font-size: 15px; } +.send-button { background: #141414; } +.progress-panel { padding: 0 0 24px; border: 0; border-radius: 0; background: transparent; margin: 16px 0 28px; } +.activity-header { display: flex; gap: 14px; align-items: center; color: var(--muted); font-size: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); } +.activity-header > span:first-child { color: var(--text); margin-right: auto; font-weight: 500; } +.activity-stream { list-style: none; padding: 8px 0; margin: 0; } +.activity-stream li { display: grid; grid-template-columns: 24px minmax(0, 1fr) 54px; gap: 12px; padding: 11px 0; align-items: baseline; font-size: 14px; } +.activity-index { font: 11px var(--mono); color: var(--muted); } +.activity-state { color: var(--muted); font-size: 11px; text-align: right; } +.activity-stream .active { color: #1d5fbf; } +.activity-stream .active .activity-state { color: #1d5fbf; } +.activity-note { margin: 8px 0 0; font-size: 12px; color: var(--muted); } +.trace-link { display: block; padding: 16px 0; font-size: 13px; text-decoration: underline; text-underline-offset: 4px; } +.source-list > li { padding-top: 20px; padding-bottom: 20px; } +.sources-panel { border-left: 1px solid var(--border); padding-left: 24px; } +.run-metrics dd { overflow-wrap: anywhere; } +@media (max-width: 1100px) { .content-layout { gap: 28px; grid-template-columns: minmax(0, 1fr) 260px; padding: 28px 24px; } } +@media (max-width: 900px) { .sidebar { display: none; } .main-column { margin-left: 0; width: 100%; } } +@media (max-width: 680px) { .content-layout { display: flex; flex-direction: column; padding: 24px 20px; } .answer-column, .sources-panel { width: 100%; max-width: none; } .sources-panel { border-left: 0; border-top: 1px solid var(--border); padding: 24px 0 0; } .empty-state { min-height: 280px; } .topbar { padding: 0 20px; } .private-mark { display: none; } } +body { --body: var(--font-geist), Arial, sans-serif; --mono: var(--font-geist-mono), Consolas, monospace; font-family: var(--body); } +.empty-state h2 { font-size: clamp(26px, 2.5vw, 34px); max-width: 440px; } +.gen-answer-card { background: #fafafa; border-color: var(--border); border-left-color: #1d5fbf; } +.activity-history { margin: 0 0 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--muted); } +.activity-history summary { cursor: pointer; } +.activity-history ol { padding-left: 20px; line-height: 1.9; } +@media (min-width: 901px) { + .sidebar { width: 64px; align-items: center; padding: 24px 8px 14px; } + .brand { gap: 0; padding: 0; font-size: 17px; } + .workspace-switcher, .nav-section, .nav-count { display: none; } + .primary-nav { margin-top: 48px; gap: 8px; } + .nav-link { width: 44px; min-height: 44px; justify-content: center; gap: 0; padding: 0; font-size: 13px; } + .sidebar-bottom { padding: 12px; } + .sidebar-bottom > div:not(.service-dot) { display: none; } + .main-column { width: calc(100% - 64px); margin-left: 64px; } + .content-layout { max-width: 1280px; } +} +.privacy-note, .source-location, .request-id, .composer-footer, .sources-empty, .source-index, .sources-heading p { color: #626262; } +@media (max-width: 500px) { + .breadcrumb { display: flex; } + .breadcrumb .crumb-sep, .breadcrumb strong { display: none; } + .topbar-actions { width: auto; } + .genui-status { display: none; } +} +.empty-eyebrow { font-family: var(--body); font-size: 13px; letter-spacing: 0; } +@media (min-width: 901px) { + .rail-label { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 0725935..44be092 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,4 +1,8 @@ import type { Metadata } from "next"; +import localFont from "next/font/local"; + +const geist = localFont({ src: "./fonts/geist-latin.woff2", variable: "--font-geist", display: "swap" }); +const mono = localFont({ src: "./fonts/geist-mono-latin.woff2", variable: "--font-geist-mono", display: "swap" }); import "./globals.css"; import "./genui.css"; @@ -14,7 +18,7 @@ export default function RootLayout({ }>) { return ( - {children} + {children} ); } diff --git a/frontend/src/components/workspace.tsx b/frontend/src/components/workspace.tsx index 1816fcc..5adc215 100644 --- a/frontend/src/components/workspace.tsx +++ b/frontend/src/components/workspace.tsx @@ -1,14 +1,15 @@ "use client"; -import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; +import { FormEvent, KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { citationLinks, VerifiedUI } from "@/lib/verified-ui"; import type { VerifiedSource } from "@/lib/verified-ui-contract"; -type Stage = { name: string; state: "active" | "complete" }; +type Stage = { name: string; message?: string; state: "active" | "complete" }; type FinalEvent = { request_id?: string; + langsmith_url?: string; status: "answered" | "needs_clarification" | "insufficient_evidence" | string; answer?: string; sources?: VerifiedSource[]; @@ -28,6 +29,21 @@ function label(value: string) { return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); } +const activityLabels: Record = { + queued: "Waiting for the research worker", + searching_and_selecting_evidence: "Searching articles and ranking evidence", + writing_and_verifying: "Preparing a source-backed answer", + write: "Writing from the selected evidence", + verify: "Checking claims and citations", + repair: "Revising claims that need support", + validating_presentation: "Preparing and checking the answer layout", + understanding_followup: "Connecting your follow-up to this session", +}; + +function describeStage(stage: Stage) { + return stage.message || activityLabels[stage.name] || label(stage.name); +} + function statusLabel(status: string) { if (["direct_groq"].includes(status.toLowerCase())) return "Groq configured · OpenUI"; if (["verified_text_fallback", "text_fallback"].includes(status.toLowerCase())) return "Verified text only"; @@ -86,14 +102,16 @@ async function* readEvents(response: Response) { } export default function Workspace() { + const [elapsed, setElapsed] = useState(0); const [question, setQuestion] = useState(""); const [busy, setBusy] = useState(false); const [stages, setStages] = useState([]); + const [retrievedSources, setRetrievedSources] = useState([]); const [result, setResult] = useState(null); const [error, setError] = useState(""); + const [failedRun, setFailedRun] = useState | null>(null); const [serviceStatus, setServiceStatus] = useState(null); - const [controller, setController] = useState(null); - const [answerProgress, setAnswerProgress] = useState<{ runId: number; text: string }>({ runId: -1, text: "" }); + const controllerRef = useRef(null); const [openUiMessage, setOpenUiMessage] = useState(""); const inputRef = useRef(null); const runCounter = useRef(0); @@ -112,9 +130,10 @@ export default function Workspace() { setQuestion(""); setResult(null); setStages([]); + setRetrievedSources([]); setError(""); + setFailedRun(null); setOpenUiMessage(""); - setAnswerProgress({ runId: -1, text: "" }); }; window.addEventListener("pagehide", endSessionOnClose); window.addEventListener("pageshow", resetAfterRestore); @@ -125,33 +144,26 @@ export default function Workspace() { }, []); useEffect(() => { - if (!result?.answer || result.status !== "answered") return; - const runId = result.runId; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - const frame = window.requestAnimationFrame(() => setAnswerProgress({ runId, text: result.answer! })); - return () => window.cancelAnimationFrame(frame); - } - let index = 0; - const step = Math.max(10, Math.ceil(result.answer.length / 80)); - const timer = window.setInterval(() => { - index = Math.min(result.answer!.length, index + step); - setAnswerProgress({ runId, text: result.answer!.slice(0, index) }); - if (index === result.answer!.length) window.clearInterval(timer); - }, 16); + if (!busy) return; + const started = Date.now(); + const timer = window.setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); return () => window.clearInterval(timer); - }, [result?.runId, result?.answer, result?.status]); + }, [busy]); async function ask(text: string, regenerate = false) { const submitted = text; - if (!submitted.trim() || busy) return; + if (!submitted.trim() || busy || controllerRef.current) return; const aborter = new AbortController(); const runId = ++runCounter.current; - setController(aborter); + controllerRef.current = aborter; setBusy(true); + setElapsed(0); setError(""); + setFailedRun(null); setOpenUiMessage(""); setResult(null); setStages([]); + setRetrievedSources([]); setQuestion(submitted); try { const response = await fetch("/api/ask", { @@ -174,8 +186,11 @@ export default function Workspace() { const type = event.event || String(event.data.type || ""); if (type === "stage" || typeof event.data.stage === "string") { const stage = String(event.data.stage || "working"); - setStages((current) => [...current.map((item) => ({ ...item, state: "complete" as const })), { name: stage, state: "active" }]); + setStages((current) => [...current.map((item) => ({ ...item, state: "complete" as const })), { name: stage, message: typeof event.data.message === "string" ? event.data.message : undefined, state: "active" }]); + } else if (type === "evidence" && Array.isArray(event.data.sources) && event.data.sources.every(validSource)) { + setRetrievedSources(event.data.sources); } else if (type === "error") { + setFailedRun(event.data as FinalEvent); throw new Error(String(event.data.message || "The request failed.")); } else if (type === "final") { final = event.data as FinalEvent; @@ -198,7 +213,7 @@ export default function Workspace() { body: JSON.stringify({ openui: final.openui, sources: final.sources || [] }), }); const checked = await validation.json(); - if (validation.ok && checked.valid) setResult((current) => current ? { ...current, genUiValid: true } : current); + if (validation.ok && checked.valid) setResult((current) => current?.runId === runId ? { ...current, genUiValid: true } : current); else setOpenUiMessage("The generated layout did not pass validation. Showing the verified text answer."); } catch { setOpenUiMessage("Generated layout validation is unavailable. Showing the verified text answer."); @@ -209,13 +224,20 @@ export default function Workspace() { else setError(caught instanceof Error ? caught.message : "Something went wrong."); } finally { setBusy(false); - setController(null); + if (controllerRef.current === aborter) controllerRef.current = null; } } + function stopRequest(event: MouseEvent) { + // Cancellation can switch this same button back to submit during the click. + event.preventDefault(); + controllerRef.current?.abort(); + } + async function newSession() { if (busy) return; setError(""); + setFailedRun(null); try { const response = await fetch("/api/session/end", { method: "POST", credentials: "include" }); if (!response.ok) { @@ -227,9 +249,10 @@ export default function Workspace() { setQuestion(""); setResult(null); setStages([]); + setRetrievedSources([]); setError(""); + setFailedRun(null); setOpenUiMessage(""); - setAnswerProgress({ runId: -1, text: "" }); inputRef.current?.focus(); } catch (caught) { setError(caught instanceof Error ? caught.message : "Could not end this session."); @@ -248,7 +271,8 @@ export default function Workspace() { } } - const sources = result?.sources || []; + const sources = result?.sources || retrievedSources; + const runDetails = result || failedRun; const showGeneratedUi = result?.status === "answered" && result.genUiValid && result.openui; return ( @@ -256,7 +280,7 @@ export default function Workspace() {
-
Knowledge base/Ask
+
Fieldnote/IBM TechQA
{serviceStatus ? statusLabel(serviceStatus.presentation_status) : "Checking OpenUI"} @@ -289,7 +313,7 @@ export default function Workspace() {
-

Research workspace

+

Technical research

Search IBM TechQA articles. Answers appear after verification.

Session only @@ -297,17 +321,25 @@ export default function Workspace() { {!result && !busy && !error && (
- -

What do you need to find?

-

Search your documents for a precise, source-backed answer.

+ IBM TechQA +

Ask about an IBM product issue

+

Describe the product, version and what went wrong. Inspect the sources behind every answer.

)} {busy && (
-
- {stages.length > 0 &&
    {stages.map((stage, index) =>
  1. {stage.state === "complete" ? "✓" : "·"}{label(stage.name)}
  2. )}
} - +
Live activity{elapsed}s
+
    + {stages.map((stage, index) => ( +
  1. + + {describeStage(stage)} + {stage.state === "complete" ? "Done" : "Running"} +
  2. + ))} +
+

The answer will appear after its claims and citations are checked.

)} @@ -315,10 +347,11 @@ export default function Workspace() { {result && (
+ {stages.length > 0 &&
Research activity · {elapsed}s
    {stages.map((stage, index) =>
  1. {describeStage(stage)}
  2. )}
}

{result.question}

{result.status === "answered" ? ( <> -
Verified answer{result.request_id && Run {result.request_id.slice(0, 10)}}
+
Verified answer{result.request_id && Run {result.request_id.slice(0, 10)}}
{showGeneratedUi &&
{ setResult((current) => current ? { ...current, genUiValid: false } : current); setOpenUiMessage("The generated layout could not render. Showing the verified text answer."); @@ -335,7 +368,7 @@ export default function Workspace() { }, img: () => null, }} - >{answerProgress.runId === result.runId ? answerProgress.text : ""} + >{result.answer}
} {openUiMessage &&

{openUiMessage}

}
Verified against {sources.length} {sources.length === 1 ? "source" : "sources"}
@@ -352,20 +385,21 @@ export default function Workspace() {